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 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy