From 1cffd11619e14609e6778f70830b6a4d38d00c78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B1=E3=82=A4=E3=83=A9?= Date: Thu, 31 Jul 2025 09:05:09 -0600 Subject: [PATCH] feat: add workspace sharing page (#19107) --- coderd/apidoc/docs.go | 77 ++++++++- coderd/apidoc/swagger.json | 67 +++++++- coderd/coderd.go | 6 + coderd/coderdtest/swaggerparser.go | 3 +- coderd/database/db2sdk/db2sdk.go | 24 +++ coderd/database/dbauthz/dbauthz.go | 12 ++ coderd/database/dbauthz/dbauthz_test.go | 16 ++ coderd/database/dbmetrics/querymetrics.go | 7 + coderd/database/dbmock/dbmock.go | 14 ++ coderd/database/modelmethods.go | 4 +- coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 21 +++ coderd/database/queries/workspaces.sql | 9 + coderd/database/types.go | 11 ++ coderd/rbac/regosql/acl_mapping_var.go | 19 ++- coderd/workspaces.go | 159 ++++++++++++++++++ codersdk/workspaces.go | 27 +++ docs/reference/api/enterprise.md | 8 +- docs/reference/api/schemas.md | 40 +++++ docs/reference/api/workspaces.md | 43 +++++ enterprise/coderd/templates.go | 65 ++++--- site/src/api/api.ts | 7 + site/src/api/queries/workspaces.ts | 9 + site/src/api/typesGenerated.ts | 11 ++ .../pages/WorkspaceSettingsPage/Sidebar.tsx | 17 +- .../WorkspaceSharingPage.tsx | 36 ++++ site/src/router.tsx | 7 + site/static/kirby.gif | Bin 0 -> 32512 bytes 28 files changed, 668 insertions(+), 52 deletions(-) create mode 100644 site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx create mode 100644 site/static/kirby.gif diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index c302487341..fa2aad745e 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": [ @@ -17233,6 +17277,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": { @@ -18965,6 +19027,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 2212d91e35..e1bcc5bf10 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": [ @@ -15731,6 +15769,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": { @@ -17363,6 +17419,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 9115888fc5..26bf4a7bf9 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 d7d46711a9..7cef0d8d9f 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 320a90b094..48f6ff44af 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 72489ea92d..402097f13d 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -4919,6 +4919,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 14ab09bada..9e4f1f80fe 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 3fffb29966..574eeb069e 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -3029,6 +3029,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 20bc17117e..30589c9fbb 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -6461,6 +6461,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 5347e8de37..caf7ccce4c 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 a2c6cda1af..d812ff1a96 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -628,6 +628,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 6033ab7280..a7b61d6eab 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -20872,6 +20872,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 783cbc56e4..b6b4f2de08 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 11a0613965..01a7cce231 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 172ac4cc56..301da929ad 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 0f3f0a24c7..2080926b44 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/workspaces.go b/codersdk/workspaces.go index dee2e1b838..13cb778ab0 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 c9b65a97d2..0ffae11160 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 5d9866658c..581743ea7c 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -8149,6 +8149,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 @@ -10548,6 +10572,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 d7187259b5..70338fdeb1 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -1514,6 +1514,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 4514ba928e..438a7cfd5c 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 cd70bfaf00..2b21ddf1e8 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 05fb09314d..536925a973 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 bd14a6edf0..db901630b7 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3230,6 +3230,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; @@ -3970,6 +3976,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 91aea9ac9c..32261577da 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 0000000000..74f240050c --- /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 90a8bda22c..9f92c80f35 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 0000000000000000000000000000000000000000..b6fe7e93e1fa1f22d1dd85c3d8da48eb49365752 GIT binary patch literal 32512 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