diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7c723994d38d2..5fbe94fffa5a2 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5289,7 +5289,7 @@ const docTemplate = `{ "required": true }, { - "description": "Update template request", + "description": "Update template ACL request", "name": "request", "in": "body", "required": true, @@ -9942,6 +9942,50 @@ const docTemplate = `{ } } }, + "/workspaces/{workspace}/acl": { + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Workspaces" + ], + "summary": "Update workspace ACL", + "operationId": "update-workspace-acl", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + }, + { + "description": "Update workspace ACL request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateWorkspaceACL" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/workspaces/{workspace}/autostart": { "put": { "security": [ @@ -12833,7 +12877,8 @@ const docTemplate = `{ "workspace-usage", "web-push", "oauth2", - "mcp-server-http" + "mcp-server-http", + "workspace-sharing" ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", @@ -12842,6 +12887,7 @@ const docTemplate = `{ "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", "ExperimentOAuth2": "Enables OAuth2 provider functionality.", "ExperimentWebPush": "Enables web push notifications through the browser.", + "ExperimentWorkspaceSharing": "Enables updating workspace ACLs for sharing with users and groups.", "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking." }, "x-enum-varnames": [ @@ -12851,7 +12897,8 @@ const docTemplate = `{ "ExperimentWorkspaceUsage", "ExperimentWebPush", "ExperimentOAuth2", - "ExperimentMCPServerHTTP" + "ExperimentMCPServerHTTP", + "ExperimentWorkspaceSharing" ] }, "codersdk.ExternalAuth": { @@ -17227,6 +17274,24 @@ const docTemplate = `{ } } }, + "codersdk.UpdateWorkspaceACL": { + "type": "object", + "properties": { + "group_roles": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/codersdk.WorkspaceRole" + } + }, + "user_roles": { + "description": "Keys must be valid UUIDs. To remove a user/group from the ACL use \"\" as the\nrole name (available as a constant named ` + "`" + `codersdk.WorkspaceRoleDeleted` + "`" + `)", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/codersdk.WorkspaceRole" + } + } + } + }, "codersdk.UpdateWorkspaceAutomaticUpdatesRequest": { "type": "object", "properties": { @@ -18959,6 +19024,19 @@ const docTemplate = `{ } } }, + "codersdk.WorkspaceRole": { + "type": "string", + "enum": [ + "admin", + "use", + "" + ], + "x-enum-varnames": [ + "WorkspaceRoleAdmin", + "WorkspaceRoleUse", + "WorkspaceRoleDeleted" + ] + }, "codersdk.WorkspaceStatus": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 28a38ffd32d70..edc60e449304b 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4658,7 +4658,7 @@ "required": true }, { - "description": "Update template request", + "description": "Update template ACL request", "name": "request", "in": "body", "required": true, @@ -8792,6 +8792,44 @@ } } }, + "/workspaces/{workspace}/acl": { + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Workspaces"], + "summary": "Update workspace ACL", + "operationId": "update-workspace-acl", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + }, + { + "description": "Update workspace ACL request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateWorkspaceACL" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/workspaces/{workspace}/autostart": { "put": { "security": [ @@ -11501,7 +11539,8 @@ "workspace-usage", "web-push", "oauth2", - "mcp-server-http" + "mcp-server-http", + "workspace-sharing" ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", @@ -11510,6 +11549,7 @@ "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", "ExperimentOAuth2": "Enables OAuth2 provider functionality.", "ExperimentWebPush": "Enables web push notifications through the browser.", + "ExperimentWorkspaceSharing": "Enables updating workspace ACLs for sharing with users and groups.", "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking." }, "x-enum-varnames": [ @@ -11519,7 +11559,8 @@ "ExperimentWorkspaceUsage", "ExperimentWebPush", "ExperimentOAuth2", - "ExperimentMCPServerHTTP" + "ExperimentMCPServerHTTP", + "ExperimentWorkspaceSharing" ] }, "codersdk.ExternalAuth": { @@ -15725,6 +15766,24 @@ } } }, + "codersdk.UpdateWorkspaceACL": { + "type": "object", + "properties": { + "group_roles": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/codersdk.WorkspaceRole" + } + }, + "user_roles": { + "description": "Keys must be valid UUIDs. To remove a user/group from the ACL use \"\" as the\nrole name (available as a constant named `codersdk.WorkspaceRoleDeleted`)", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/codersdk.WorkspaceRole" + } + } + } + }, "codersdk.UpdateWorkspaceAutomaticUpdatesRequest": { "type": "object", "properties": { @@ -17357,6 +17416,15 @@ } } }, + "codersdk.WorkspaceRole": { + "type": "string", + "enum": ["admin", "use", ""], + "x-enum-varnames": [ + "WorkspaceRoleAdmin", + "WorkspaceRoleUse", + "WorkspaceRoleDeleted" + ] + }, "codersdk.WorkspaceStatus": { "type": "string", "enum": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index 9115888fc566b..26bf4a7bf9b63 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1413,6 +1413,12 @@ func New(options *Options) *API { r.Delete("/", api.deleteWorkspaceAgentPortShare) }) r.Get("/timings", api.workspaceTimings) + r.Route("/acl", func(r chi.Router) { + r.Use( + httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentWorkspaceSharing)) + + r.Patch("/", api.patchWorkspaceACL) + }) }) }) r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) { diff --git a/coderd/coderdtest/swaggerparser.go b/coderd/coderdtest/swaggerparser.go index d7d46711a9df6..7cef0d8d9f9cb 100644 --- a/coderd/coderdtest/swaggerparser.go +++ b/coderd/coderdtest/swaggerparser.go @@ -360,7 +360,8 @@ func assertProduce(t *testing.T, comment SwaggerComment) { (comment.router == "/workspaceagents/me/startup/logs" && comment.method == "patch") || (comment.router == "/licenses/{id}" && comment.method == "delete") || (comment.router == "/debug/coordinator" && comment.method == "get") || - (comment.router == "/debug/tailnet" && comment.method == "get") { + (comment.router == "/debug/tailnet" && comment.method == "get") || + (comment.router == "/workspaces/{workspace}/acl" && comment.method == "patch") { return // Exception: HTTP 200 is returned without response entity } diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 320a90b09430b..48f6ff44af70f 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -24,6 +24,7 @@ import ( "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/render" "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/coderd/workspaceapps/appurl" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk/proto" @@ -781,6 +782,29 @@ func TemplateRoleActions(role codersdk.TemplateRole) []policy.Action { return []policy.Action{} } +func WorkspaceRoleActions(role codersdk.WorkspaceRole) []policy.Action { + switch role { + case codersdk.WorkspaceRoleAdmin: + return slice.Omit( + // Small note: This intentionally includes "create" because it's sort of + // double purposed as "can edit ACL". That's maybe a bit "incorrect", but + // it's what templates do already and we're copying that implementation. + rbac.ResourceWorkspace.AvailableActions(), + // Don't let anyone delete something they can't recreate. + policy.ActionDelete, + ) + case codersdk.WorkspaceRoleUse: + return []policy.Action{ + policy.ActionApplicationConnect, + policy.ActionRead, + policy.ActionSSH, + policy.ActionWorkspaceStart, + policy.ActionWorkspaceStop, + } + } + return []policy.Action{} +} + func ConnectionLogConnectionTypeFromAgentProtoConnectionType(typ agentproto.Connection_Type) (database.ConnectionType, error) { switch typ { case agentproto.Connection_SSH: diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 257cbc6e6b142..09db45f00aa58 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -4903,6 +4903,18 @@ func (q *querier) UpdateWorkspace(ctx context.Context, arg database.UpdateWorksp return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateWorkspace)(ctx, arg) } +func (q *querier) UpdateWorkspaceACLByID(ctx context.Context, arg database.UpdateWorkspaceACLByIDParams) error { + fetch := func(ctx context.Context, arg database.UpdateWorkspaceACLByIDParams) (database.WorkspaceTable, error) { + w, err := q.db.GetWorkspaceByID(ctx, arg.ID) + if err != nil { + return database.WorkspaceTable{}, err + } + return w.WorkspaceTable(), nil + } + + return fetchAndExec(q.log, q.auth, policy.ActionCreate, fetch, q.db.UpdateWorkspaceACLByID)(ctx, arg) +} + func (q *querier) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg database.UpdateWorkspaceAgentConnectionByIDParams) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index dc86d598617fd..da9f2c426ee6d 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2146,6 +2146,22 @@ func (s *MethodTestSuite) TestWorkspace() { // no asserts here because SQLFilter check.Args([]uuid.UUID{}, emptyPreparedAuthorized{}).Asserts() })) + s.Run("UpdateWorkspaceACLByID", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + o := dbgen.Organization(s.T(), db, database.Organization{}) + tpl := dbgen.Template(s.T(), db, database.Template{ + OrganizationID: o.ID, + CreatedBy: u.ID, + }) + ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ + OwnerID: u.ID, + OrganizationID: o.ID, + TemplateID: tpl.ID, + }) + check.Args(database.UpdateWorkspaceACLByIDParams{ + ID: ws.ID, + }).Asserts(ws, policy.ActionCreate) + })) s.Run("GetLatestWorkspaceBuildByWorkspaceID", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) o := dbgen.Organization(s.T(), db, database.Organization{}) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 811d945ac7da9..00802c0e43d2e 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -3015,6 +3015,13 @@ func (m queryMetricsStore) UpdateWorkspace(ctx context.Context, arg database.Upd return workspace, err } +func (m queryMetricsStore) UpdateWorkspaceACLByID(ctx context.Context, arg database.UpdateWorkspaceACLByIDParams) error { + start := time.Now() + r0 := m.s.UpdateWorkspaceACLByID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateWorkspaceACLByID").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg database.UpdateWorkspaceAgentConnectionByIDParams) error { start := time.Now() err := m.s.UpdateWorkspaceAgentConnectionByID(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index b20c3d06209b5..613c845fb73a3 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -6433,6 +6433,20 @@ func (mr *MockStoreMockRecorder) UpdateWorkspace(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspace", reflect.TypeOf((*MockStore)(nil).UpdateWorkspace), ctx, arg) } +// UpdateWorkspaceACLByID mocks base method. +func (m *MockStore) UpdateWorkspaceACLByID(ctx context.Context, arg database.UpdateWorkspaceACLByIDParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateWorkspaceACLByID", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateWorkspaceACLByID indicates an expected call of UpdateWorkspaceACLByID. +func (mr *MockStoreMockRecorder) UpdateWorkspaceACLByID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceACLByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceACLByID), ctx, arg) +} + // UpdateWorkspaceAgentConnectionByID mocks base method. func (m *MockStore) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg database.UpdateWorkspaceAgentConnectionByIDParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 5347e8de37ebe..caf7ccce4c6a7 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -276,7 +276,9 @@ func (w WorkspaceTable) RBACObject() rbac.Object { return rbac.ResourceWorkspace.WithID(w.ID). InOrg(w.OrganizationID). - WithOwner(w.OwnerID.String()) + WithOwner(w.OwnerID.String()). + WithGroupACL(w.GroupACL.RBACACL()). + WithACLUserList(w.UserACL.RBACACL()) } func (w WorkspaceTable) DormantRBAC() rbac.Object { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index baa5d8590b1d7..a90cb073814a1 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -626,6 +626,7 @@ type sqlcQuerier interface { UpdateUserThemePreference(ctx context.Context, arg UpdateUserThemePreferenceParams) (UserConfig, error) UpdateVolumeResourceMonitor(ctx context.Context, arg UpdateVolumeResourceMonitorParams) error UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (WorkspaceTable, error) + UpdateWorkspaceACLByID(ctx context.Context, arg UpdateWorkspaceACLByIDParams) error UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, arg UpdateWorkspaceAgentLifecycleStateByIDParams) error UpdateWorkspaceAgentLogOverflowByID(ctx context.Context, arg UpdateWorkspaceAgentLogOverflowByIDParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 5c06119e80a75..b2bfa6608fea7 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -20813,6 +20813,27 @@ func (q *sqlQuerier) UpdateWorkspace(ctx context.Context, arg UpdateWorkspacePar return i, err } +const updateWorkspaceACLByID = `-- name: UpdateWorkspaceACLByID :exec +UPDATE + workspaces +SET + group_acl = $1, + user_acl = $2 +WHERE + id = $3 +` + +type UpdateWorkspaceACLByIDParams struct { + GroupACL WorkspaceACL `db:"group_acl" json:"group_acl"` + UserACL WorkspaceACL `db:"user_acl" json:"user_acl"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateWorkspaceACLByID(ctx context.Context, arg UpdateWorkspaceACLByIDParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceACLByID, arg.GroupACL, arg.UserACL, arg.ID) + return err +} + const updateWorkspaceAutomaticUpdates = `-- name: UpdateWorkspaceAutomaticUpdates :exec UPDATE workspaces diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 783cbc56e488c..b6b4f2de0888f 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -873,3 +873,12 @@ GROUP BY workspaces.id, workspaces.name, latest_build.job_status, latest_build.j -- name: GetWorkspacesByTemplateID :many SELECT * FROM workspaces WHERE template_id = $1 AND deleted = false; + +-- name: UpdateWorkspaceACLByID :exec +UPDATE + workspaces +SET + group_acl = @group_acl, + user_acl = @user_acl +WHERE + id = @id; diff --git a/coderd/database/types.go b/coderd/database/types.go index 11a0613965b8d..01a7cce231061 100644 --- a/coderd/database/types.go +++ b/coderd/database/types.go @@ -91,6 +91,17 @@ func (t *WorkspaceACL) Scan(src interface{}) error { return xerrors.Errorf("unexpected type %T", src) } +//nolint:revive +func (w WorkspaceACL) RBACACL() map[string][]policy.Action { + // Convert WorkspaceACL to a map of string to []policy.Action. + // This is used for RBAC checks. + rbacACL := make(map[string][]policy.Action, len(w)) + for id, entry := range w { + rbacACL[id] = entry.Permissions + } + return rbacACL +} + func (t WorkspaceACL) Value() (driver.Value, error) { return json.Marshal(t) } diff --git a/coderd/rbac/regosql/acl_mapping_var.go b/coderd/rbac/regosql/acl_mapping_var.go index 172ac4cc56915..301da929adfbd 100644 --- a/coderd/rbac/regosql/acl_mapping_var.go +++ b/coderd/rbac/regosql/acl_mapping_var.go @@ -15,14 +15,18 @@ var ( _ sqltypes.Node = ACLMappingVar{} ) -// ACLMappingVar is a variable matcher that handles group_acl and user_acl. -// The sql type is a jsonb object with the following structure: +// ACLMappingVar is a variable matcher that matches ACL map variables to their +// SQL storage. Usually the actual backing implementation is a pair of `jsonb` +// columns named `group_acl` and `user_acl`. Each column contains an object that +// looks like... // -// "group_acl": { -// "": [""] +// ```json +// +// { +// "": ["", ""] // } // -// This is a custom variable matcher as json objects have arbitrary complexity. +// ``` type ACLMappingVar struct { // SelectSQL is used to `SELECT` the ACL mapping from the table for the // given resource. ie. if the full query might look like `SELECT group_acl @@ -59,9 +63,10 @@ func (g ACLMappingVar) UsingSubfield(subfield string) ACLMappingVar { func (ACLMappingVar) UseAs() sqltypes.Node { return ACLMappingVar{} } func (g ACLMappingVar) ConvertVariable(rego ast.Ref) (sqltypes.Node, bool) { - // "left" will be a map of group names to actions in rego. + // left is the rego variable that maps the actor's id to the actions they + // are allowed to take. // { - // "all_users": ["read"] + // "": ["", ""] // } left, err := sqltypes.RegoVarPath(g.StructPath, rego) if err != nil { diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 0f3f0a24c75d3..2080926b44089 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -2041,6 +2041,104 @@ func (api *API) workspaceTimings(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, timings) } +// @Summary Update workspace ACL +// @ID update-workspace-acl +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Workspaces +// @Param workspace path string true "Workspace ID" format(uuid) +// @Param request body codersdk.UpdateWorkspaceACL true "Update workspace ACL request" +// @Success 204 +// @Router /workspaces/{workspace}/acl [patch] +func (api *API) patchWorkspaceACL(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + workspace = httpmw.WorkspaceParam(r) + auditor = api.Auditor.Load() + aReq, commitAudit = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + OrganizationID: workspace.OrganizationID, + }) + ) + defer commitAudit() + aReq.Old = workspace.WorkspaceTable() + + var req codersdk.UpdateWorkspaceACL + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + validErrs := validateWorkspaceACLPerms(ctx, api.Database, req.UserRoles, "user_roles") + validErrs = append(validErrs, validateWorkspaceACLPerms( + ctx, + api.Database, + req.GroupRoles, + "group_roles", + )...) + + if len(validErrs) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request to update template metadata!", + Validations: validErrs, + }) + return + } + + err := api.Database.InTx(func(tx database.Store) error { + var err error + workspace, err = tx.GetWorkspaceByID(ctx, workspace.ID) + if err != nil { + return xerrors.Errorf("get template by ID: %w", err) + } + + for id, role := range req.UserRoles { + if role == codersdk.WorkspaceRoleDeleted { + delete(workspace.UserACL, id) + continue + } + workspace.UserACL[id] = database.WorkspaceACLEntry{ + Permissions: db2sdk.WorkspaceRoleActions(role), + } + } + + for id, role := range req.GroupRoles { + if role == codersdk.WorkspaceRoleDeleted { + delete(workspace.GroupACL, id) + continue + } + workspace.GroupACL[id] = database.WorkspaceACLEntry{ + Permissions: db2sdk.WorkspaceRoleActions(role), + } + } + + err = tx.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{ + ID: workspace.ID, + UserACL: workspace.UserACL, + GroupACL: workspace.GroupACL, + }) + if err != nil { + return xerrors.Errorf("update workspace ACL by ID: %w", err) + } + workspace, err = tx.GetWorkspaceByID(ctx, workspace.ID) + if err != nil { + return xerrors.Errorf("get updated workspace by ID: %w", err) + } + return nil + }, nil) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + aReq.New = workspace.WorkspaceTable() + + rw.WriteHeader(http.StatusNoContent) +} + type workspaceData struct { templates []database.Template builds []codersdk.WorkspaceBuild @@ -2379,3 +2477,64 @@ func (api *API) publishWorkspaceAgentLogsUpdate(ctx context.Context, workspaceAg api.Logger.Warn(ctx, "failed to publish workspace agent logs update", slog.F("workspace_agent_id", workspaceAgentID), slog.Error(err)) } } + +func validateWorkspaceACLPerms(ctx context.Context, db database.Store, perms map[string]codersdk.WorkspaceRole, field string) []codersdk.ValidationError { + // nolint:gocritic // Validate requires full read access to users and groups + ctx = dbauthz.AsSystemRestricted(ctx) + var validErrs []codersdk.ValidationError + for idStr, role := range perms { + if err := validateWorkspaceRole(role); err != nil { + validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: err.Error()}) + continue + } + + id, err := uuid.Parse(idStr) + if err != nil { + validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: idStr + "is not a valid UUID."}) + continue + } + + switch field { + case "user_roles": + // TODO(lilac): put this back after Kirby button shenanigans are over + // This could get slow if we get a ton of user perm updates. + // _, err = db.GetUserByID(ctx, id) + // if err != nil { + // validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: fmt.Sprintf("Failed to find resource with ID %q: %v", idStr, err.Error())}) + // continue + // } + case "group_roles": + // This could get slow if we get a ton of group perm updates. + _, err = db.GetGroupByID(ctx, id) + if err != nil { + validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: fmt.Sprintf("Failed to find resource with ID %q: %v", idStr, err.Error())}) + continue + } + default: + validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: "invalid field"}) + } + } + + return validErrs +} + +func validateWorkspaceRole(role codersdk.WorkspaceRole) error { + actions := db2sdk.WorkspaceRoleActions(role) + if len(actions) == 0 && role != codersdk.WorkspaceRoleDeleted { + return xerrors.Errorf("role %q is not a valid Workspace role", role) + } + + return nil +} + +// TODO: This will go here +// func convertToWorkspaceRole(actions []policy.Action) codersdk.TemplateRole { +// switch { +// case len(actions) == 2 && slice.SameElements(actions, []policy.Action{policy.ActionUse, policy.ActionRead}): +// return codersdk.TemplateRoleUse +// case len(actions) == 1 && actions[0] == policy.WildcardSymbol: +// return codersdk.TemplateRoleAdmin +// } + +// return "" +// } diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 3844523063db7..1d6fa4572772e 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -3432,6 +3432,7 @@ const ( ExperimentWebPush Experiment = "web-push" // Enables web push notifications through the browser. ExperimentOAuth2 Experiment = "oauth2" // Enables OAuth2 provider functionality. ExperimentMCPServerHTTP Experiment = "mcp-server-http" // Enables the MCP HTTP server functionality. + ExperimentWorkspaceSharing Experiment = "workspace-sharing" // Enables updating workspace ACLs for sharing with users and groups. ) func (e Experiment) DisplayName() string { @@ -3450,6 +3451,8 @@ func (e Experiment) DisplayName() string { return "OAuth2 Provider Functionality" case ExperimentMCPServerHTTP: return "MCP HTTP Server Functionality" + case ExperimentWorkspaceSharing: + return "Workspace Sharing" default: // Split on hyphen and convert to title case // e.g. "web-push" -> "Web Push", "mcp-server-http" -> "Mcp Server Http" @@ -3467,6 +3470,7 @@ var ExperimentsKnown = Experiments{ ExperimentWebPush, ExperimentOAuth2, ExperimentMCPServerHTTP, + ExperimentWorkspaceSharing, } // ExperimentsSafe should include all experiments that are safe for diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index dee2e1b838cb9..13cb778ab0ae0 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -662,3 +662,30 @@ func (c *Client) WorkspaceTimings(ctx context.Context, id uuid.UUID) (WorkspaceB var timings WorkspaceBuildTimings return timings, json.NewDecoder(res.Body).Decode(&timings) } + +type UpdateWorkspaceACL struct { + // Keys must be valid UUIDs. To remove a user/group from the ACL use "" as the + // role name (available as a constant named `codersdk.WorkspaceRoleDeleted`) + UserRoles map[string]WorkspaceRole `json:"user_roles,omitempty"` + GroupRoles map[string]WorkspaceRole `json:"group_roles,omitempty"` +} + +type WorkspaceRole string + +const ( + WorkspaceRoleAdmin WorkspaceRole = "admin" + WorkspaceRoleUse WorkspaceRole = "use" + WorkspaceRoleDeleted WorkspaceRole = "" +) + +func (c *Client) UpdateWorkspaceACL(ctx context.Context, workspaceID uuid.UUID, req UpdateWorkspaceACL) error { + res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/workspaces/%s/acl", workspaceID), req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index c9b65a97d2f03..0ffae1116097d 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -3582,10 +3582,10 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template}/acl \ ### Parameters -| Name | In | Type | Required | Description | -|------------|------|--------------------------------------------------------------------|----------|-------------------------| -| `template` | path | string(uuid) | true | Template ID | -| `body` | body | [codersdk.UpdateTemplateACL](schemas.md#codersdkupdatetemplateacl) | true | Update template request | +| Name | In | Type | Required | Description | +|------------|------|--------------------------------------------------------------------|----------|-----------------------------| +| `template` | path | string(uuid) | true | Template ID | +| `body` | body | [codersdk.UpdateTemplateACL](schemas.md#codersdkupdatetemplateacl) | true | Update template ACL request | ### Example responses diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 033ef6e196972..0f585b11ced90 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -3320,6 +3320,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `web-push` | | `oauth2` | | `mcp-server-http` | +| `workspace-sharing` | ## codersdk.ExternalAuth @@ -8145,6 +8146,30 @@ Restarts will only happen on weekdays in this list on weeks which line up with W The schedule must be daily with a single time, and should have a timezone specified via a CRON_TZ prefix (otherwise UTC will be used). If the schedule is empty, the user will be updated to use the default schedule.| +## codersdk.UpdateWorkspaceACL + +```json +{ + "group_roles": { + "property1": "admin", + "property2": "admin" + }, + "user_roles": { + "property1": "admin", + "property2": "admin" + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------------|--------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| +| `group_roles` | object | false | | | +| » `[any property]` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | +| `user_roles` | object | false | | Keys must be valid UUIDs. To remove a user/group from the ACL use "" as the role name (available as a constant named `codersdk.WorkspaceRoleDeleted`) | +| » `[any property]` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | + ## codersdk.UpdateWorkspaceAutomaticUpdatesRequest ```json @@ -10542,6 +10567,22 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `sensitive` | boolean | false | | | | `value` | string | false | | | +## codersdk.WorkspaceRole + +```json +"admin" +``` + +### Properties + +#### Enumerated Values + +| Value | +|---------| +| `admin` | +| `use` | +| `` | + ## codersdk.WorkspaceStatus ```json diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index debcb421e02e3..a18ede2fd7866 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -1509,6 +1509,49 @@ curl -X PATCH http://coder-server:8080/api/v2/workspaces/{workspace} \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Update workspace ACL + +### Code samples + +```shell +# Example request using curl +curl -X PATCH http://coder-server:8080/api/v2/workspaces/{workspace}/acl \ + -H 'Content-Type: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PATCH /workspaces/{workspace}/acl` + +> Body parameter + +```json +{ + "group_roles": { + "property1": "admin", + "property2": "admin" + }, + "user_roles": { + "property1": "admin", + "property2": "admin" + } +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|-------------|------|----------------------------------------------------------------------|----------|------------------------------| +| `workspace` | path | string(uuid) | true | Workspace ID | +| `body` | body | [codersdk.UpdateWorkspaceACL](schemas.md#codersdkupdateworkspaceacl) | true | Update workspace ACL request | + +### Responses + +| Status | Meaning | Description | Schema | +|--------|-----------------------------------------------------------------|-------------|--------| +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Update workspace autostart schedule by ID ### Code samples diff --git a/enterprise/coderd/templates.go b/enterprise/coderd/templates.go index 4514ba928e21a..438a7cfd5c65f 100644 --- a/enterprise/coderd/templates.go +++ b/enterprise/coderd/templates.go @@ -184,7 +184,7 @@ func (api *API) templateACL(rw http.ResponseWriter, r *http.Request) { // @Produce json // @Tags Enterprise // @Param template path string true "Template ID" format(uuid) -// @Param request body codersdk.UpdateTemplateACL true "Update template request" +// @Param request body codersdk.UpdateTemplateACL true "Update template ACL request" // @Success 200 {object} codersdk.Response // @Router /templates/{template}/acl [patch] func (api *API) patchTemplateACL(rw http.ResponseWriter, r *http.Request) { @@ -208,9 +208,13 @@ func (api *API) patchTemplateACL(rw http.ResponseWriter, r *http.Request) { return } - validErrs := validateTemplateACLPerms(ctx, api.Database, req.UserPerms, "user_perms", true) - validErrs = append(validErrs, - validateTemplateACLPerms(ctx, api.Database, req.GroupPerms, "group_perms", false)...) + validErrs := validateTemplateACLPerms(ctx, api.Database, req.UserPerms, "user_perms") + validErrs = append(validErrs, validateTemplateACLPerms( + ctx, + api.Database, + req.GroupPerms, + "group_perms", + )...) if len(validErrs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -227,28 +231,20 @@ func (api *API) patchTemplateACL(rw http.ResponseWriter, r *http.Request) { return xerrors.Errorf("get template by ID: %w", err) } - if len(req.UserPerms) > 0 { - for id, role := range req.UserPerms { - // A user with an empty string implies - // deletion. - if role == "" { - delete(template.UserACL, id) - continue - } - template.UserACL[id] = db2sdk.TemplateRoleActions(role) + for id, role := range req.UserPerms { + if role == codersdk.TemplateRoleDeleted { + delete(template.UserACL, id) + continue } + template.UserACL[id] = db2sdk.TemplateRoleActions(role) } - if len(req.GroupPerms) > 0 { - for id, role := range req.GroupPerms { - // An id with an empty string implies - // deletion. - if role == "" { - delete(template.GroupACL, id) - continue - } - template.GroupACL[id] = db2sdk.TemplateRoleActions(role) + for id, role := range req.GroupPerms { + if role == codersdk.TemplateRoleDeleted { + delete(template.GroupACL, id) + continue } + template.GroupACL[id] = db2sdk.TemplateRoleActions(role) } err = tx.UpdateTemplateACLByID(ctx, database.UpdateTemplateACLByIDParams{ @@ -277,38 +273,39 @@ func (api *API) patchTemplateACL(rw http.ResponseWriter, r *http.Request) { }) } -// nolint TODO fix stupid flag. -func validateTemplateACLPerms(ctx context.Context, db database.Store, perms map[string]codersdk.TemplateRole, field string, isUser bool) []codersdk.ValidationError { - // Validate requires full read access to users and groups - // nolint:gocritic +func validateTemplateACLPerms(ctx context.Context, db database.Store, perms map[string]codersdk.TemplateRole, field string) []codersdk.ValidationError { + // nolint:gocritic // Validate requires full read access to users and groups ctx = dbauthz.AsSystemRestricted(ctx) var validErrs []codersdk.ValidationError - for k, v := range perms { - if err := validateTemplateRole(v); err != nil { + for idStr, role := range perms { + if err := validateTemplateRole(role); err != nil { validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: err.Error()}) continue } - id, err := uuid.Parse(k) + id, err := uuid.Parse(idStr) if err != nil { - validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: "ID " + k + "must be a valid UUID."}) + validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: idStr + "is not a valid UUID."}) continue } - if isUser { + switch field { + case "user_perms": // This could get slow if we get a ton of user perm updates. _, err = db.GetUserByID(ctx, id) if err != nil { - validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: fmt.Sprintf("Failed to find resource with ID %q: %v", k, err.Error())}) + validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: fmt.Sprintf("Failed to find resource with ID %q: %v", idStr, err.Error())}) continue } - } else { + case "group_perms": // This could get slow if we get a ton of group perm updates. _, err = db.GetGroupByID(ctx, id) if err != nil { - validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: fmt.Sprintf("Failed to find resource with ID %q: %v", k, err.Error())}) + validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: fmt.Sprintf("Failed to find resource with ID %q: %v", idStr, err.Error())}) continue } + default: + validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: "invalid field"}) } } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index cd70bfaf00600..2b21ddf1e8a08 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1896,6 +1896,13 @@ class ApiMethods { return response.data; }; + updateWorkspaceACL = async ( + workspaceId: string, + data: TypesGen.UpdateWorkspaceACL, + ): Promise => { + await this.axios.patch(`/api/v2/workspaces/${workspaceId}/acl`, data); + }; + getApplicationsHost = async (): Promise => { const response = await this.axios.get("/api/v2/applications/host"); return response.data; diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index 05fb09314d741..536925a97390f 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -3,6 +3,7 @@ import { DetailedError, isApiValidationError } from "api/errors"; import type { CreateWorkspaceRequest, ProvisionerLogLevel, + UpdateWorkspaceACL, UsageAppName, Workspace, WorkspaceAgentLog, @@ -421,3 +422,11 @@ export const workspacePermissions = (workspace?: Workspace) => { staleTime: Number.POSITIVE_INFINITY, }; }; + +export const updateWorkspaceACL = (workspaceId: string) => { + return { + mutationFn: async (patch: UpdateWorkspaceACL) => { + await API.updateWorkspaceACL(workspaceId, patch); + }, + }; +}; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6165198c6fa23..d2c88191160ad 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -917,6 +917,7 @@ export type Experiment = | "notifications" | "oauth2" | "web-push" + | "workspace-sharing" | "workspace-usage"; export const Experiments: Experiment[] = [ @@ -926,6 +927,7 @@ export const Experiments: Experiment[] = [ "notifications", "oauth2", "web-push", + "workspace-sharing", "workspace-usage", ]; @@ -3227,6 +3229,12 @@ export interface UpdateUserQuietHoursScheduleRequest { readonly schedule: string; } +// From codersdk/workspaces.go +export interface UpdateWorkspaceACL { + readonly user_roles?: Record; + readonly group_roles?: Record; +} + // From codersdk/workspaces.go export interface UpdateWorkspaceAutomaticUpdatesRequest { readonly automatic_updates: AutomaticUpdates; @@ -3967,6 +3975,11 @@ export interface WorkspaceResourceMetadata { readonly sensitive: boolean; } +// From codersdk/workspaces.go +export type WorkspaceRole = "admin" | "" | "use"; + +export const WorkspaceRoles: WorkspaceRole[] = ["admin", "", "use"]; + // From codersdk/workspacebuilds.go export type WorkspaceStatus = | "canceled" diff --git a/site/src/pages/WorkspaceSettingsPage/Sidebar.tsx b/site/src/pages/WorkspaceSettingsPage/Sidebar.tsx index 91aea9ac9cf12..32261577da9b2 100644 --- a/site/src/pages/WorkspaceSettingsPage/Sidebar.tsx +++ b/site/src/pages/WorkspaceSettingsPage/Sidebar.tsx @@ -5,9 +5,13 @@ import { SidebarHeader, SidebarNavItem, } from "components/Sidebar/Sidebar"; -import { CodeIcon as ParameterIcon } from "lucide-react"; -import { SettingsIcon as GeneralIcon } from "lucide-react"; -import { TimerIcon as ScheduleIcon } from "lucide-react"; +import { + SettingsIcon as GeneralIcon, + CodeIcon as ParameterIcon, + TimerIcon as ScheduleIcon, + Users as SharingIcon, +} from "lucide-react"; +import { useDashboard } from "modules/dashboard/useDashboard"; import type { FC } from "react"; interface SidebarProps { @@ -16,6 +20,8 @@ interface SidebarProps { } export const Sidebar: FC = ({ username, workspace }) => { + const { experiments } = useDashboard(); + return ( = ({ username, workspace }) => { Schedule + {experiments.includes("workspace-sharing") && ( + + Sharing + + )} ); }; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx new file mode 100644 index 0000000000000..74f240050c601 --- /dev/null +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx @@ -0,0 +1,36 @@ +import { updateWorkspaceACL } from "api/queries/workspaces"; +import { Button } from "components/Button/Button"; +import { ExternalImage } from "components/ExternalImage/ExternalImage"; +import type { FC } from "react"; +import { useMutation } from "react-query"; +import { useWorkspaceSettings } from "../WorkspaceSettingsLayout"; + +const localKirbyId = "1ce34e51-3135-4720-8bfc-eabce178eafb"; +const devKirbyId = "7a4319a5-0dc1-41e1-95e4-f31e312b0ecc"; + +const WorkspaceSharingPage: FC = () => { + const workspace = useWorkspaceSettings(); + const shareWithKirbyMutation = useMutation(updateWorkspaceACL(workspace.id)); + + const onClick = () => { + shareWithKirbyMutation.mutate({ + user_roles: { + [localKirbyId]: "admin", + [devKirbyId]: "admin", + }, + }); + }; + + return ( + + ); +}; + +export default WorkspaceSharingPage; diff --git a/site/src/router.tsx b/site/src/router.tsx index 90a8bda22c1f3..9f92c80f35f0f 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -86,6 +86,12 @@ const WorkspaceParametersExperimentRouter = lazy( "./pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersExperimentRouter" ), ); +const WorkspaceSharingPage = lazy( + () => + import( + "./pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage" + ), +); const TerminalPage = lazy(() => import("./pages/TerminalPage/TerminalPage")); const TemplatePermissionsPage = lazy( () => @@ -547,6 +553,7 @@ export const router = createBrowserRouter( element={} /> } /> + } /> diff --git a/site/static/kirby.gif b/site/static/kirby.gif new file mode 100644 index 0000000000000..b6fe7e93e1fa1 Binary files /dev/null and b/site/static/kirby.gif differ