diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 826b3ee035f0b..9e9d7d5e8773c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10115,6 +10115,33 @@ const docTemplate = `{ } } }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": [ + "Workspaces" + ], + "summary": "Completely clears the workspace's user and group ACLs.", + "operationId": "completely-clears-the-workspaces-user-and-group-acls", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + }, "patch": { "security": [ { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 1d83a08471a80..b550a19438e34 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8945,6 +8945,31 @@ } } }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": ["Workspaces"], + "summary": "Completely clears the workspace's user and group ACLs.", + "operationId": "completely-clears-the-workspaces-user-and-group-acls", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + }, "patch": { "security": [ { diff --git a/coderd/coderd.go b/coderd/coderd.go index 6f80286395eb8..eb4436cb160c1 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1457,6 +1457,7 @@ func New(options *Options) *API { r.Get("/", api.workspaceACL) r.Patch("/", api.patchWorkspaceACL) + r.Delete("/", api.deleteWorkspaceACL) }) }) }) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index f746b9f8d69a5..e38a174f83e8a 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1733,6 +1733,18 @@ func (q *querier) DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUI return q.db.DeleteWebpushSubscriptions(ctx, ids) } +func (q *querier) DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) error { + fetch := func(ctx context.Context, id uuid.UUID) (database.WorkspaceTable, error) { + w, err := q.db.GetWorkspaceByID(ctx, id) + if err != nil { + return database.WorkspaceTable{}, err + } + return w.WorkspaceTable(), nil + } + + return fetchAndExec(q.log, q.auth, policy.ActionUpdate, fetch, q.db.DeleteWorkspaceACLByID)(ctx, id) +} + func (q *querier) DeleteWorkspaceAgentPortShare(ctx context.Context, arg database.DeleteWorkspaceAgentPortShareParams) error { w, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index a0a3e991e6989..08e87b80c6076 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1699,6 +1699,12 @@ func (s *MethodTestSuite) TestWorkspace() { dbm.EXPECT().UpdateWorkspaceACLByID(gomock.Any(), arg).Return(nil).AnyTimes() check.Args(arg).Asserts(w, policy.ActionCreate) })) + s.Run("DeleteWorkspaceACLByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().DeleteWorkspaceACLByID(gomock.Any(), w.ID).Return(nil).AnyTimes() + check.Args(w.ID).Asserts(w, policy.ActionUpdate) + })) s.Run("GetLatestWorkspaceBuildByWorkspaceID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { w := testutil.Fake(s.T(), faker, database.Workspace{}) b := testutil.Fake(s.T(), faker, database.WorkspaceBuild{WorkspaceID: w.ID}) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index f89e68f02938d..014ec0c12880e 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -488,6 +488,13 @@ func (m queryMetricsStore) DeleteWebpushSubscriptions(ctx context.Context, ids [ return r0 } +func (m queryMetricsStore) DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) error { + start := time.Now() + r0 := m.s.DeleteWorkspaceACLByID(ctx, id) + m.queryLatencies.WithLabelValues("DeleteWorkspaceACLByID").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) DeleteWorkspaceAgentPortShare(ctx context.Context, arg database.DeleteWorkspaceAgentPortShareParams) error { start := time.Now() r0 := m.s.DeleteWorkspaceAgentPortShare(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index ce02050afb2f5..2a33ea2d52a95 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -892,6 +892,20 @@ func (mr *MockStoreMockRecorder) DeleteWebpushSubscriptions(ctx, ids any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWebpushSubscriptions", reflect.TypeOf((*MockStore)(nil).DeleteWebpushSubscriptions), ctx, ids) } +// DeleteWorkspaceACLByID mocks base method. +func (m *MockStore) DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteWorkspaceACLByID", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteWorkspaceACLByID indicates an expected call of DeleteWorkspaceACLByID. +func (mr *MockStoreMockRecorder) DeleteWorkspaceACLByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWorkspaceACLByID", reflect.TypeOf((*MockStore)(nil).DeleteWorkspaceACLByID), ctx, id) +} + // DeleteWorkspaceAgentPortShare mocks base method. func (m *MockStore) DeleteWorkspaceAgentPortShare(ctx context.Context, arg database.DeleteWorkspaceAgentPortShareParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index b5ef14f6b86b5..1c46afa39821e 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -122,6 +122,7 @@ type sqlcQuerier interface { DeleteUserSecret(ctx context.Context, id uuid.UUID) error DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg DeleteWebpushSubscriptionByUserIDAndEndpointParams) error DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error + DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) error DeleteWorkspaceAgentPortShare(ctx context.Context, arg DeleteWorkspaceAgentPortShareParams) error DeleteWorkspaceAgentPortSharesByTemplate(ctx context.Context, templateID uuid.UUID) error DeleteWorkspaceSubAgentByID(ctx context.Context, id uuid.UUID) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 009ed129019b7..ebff2c5453150 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -20141,6 +20141,21 @@ func (q *sqlQuerier) BatchUpdateWorkspaceNextStartAt(ctx context.Context, arg Ba return err } +const deleteWorkspaceACLByID = `-- name: DeleteWorkspaceACLByID :exec +UPDATE + workspaces +SET + group_acl = '{}'::json, + user_acl = '{}'::json +WHERE + id = $1 +` + +func (q *sqlQuerier) DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteWorkspaceACLByID, id) + return err +} + const favoriteWorkspace = `-- name: FavoriteWorkspace :exec UPDATE workspaces SET favorite = true WHERE id = $1 ` diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 80d8c7b920d74..a4a3200f5c3d5 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -924,6 +924,15 @@ SET WHERE id = @id; +-- name: DeleteWorkspaceACLByID :exec +UPDATE + workspaces +SET + group_acl = '{}'::json, + user_acl = '{}'::json +WHERE + id = @id; + -- name: GetRegularWorkspaceCreateMetrics :many -- Count regular workspaces: only those whose first successful 'start' build -- was not initiated by the prebuild system user. diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 64ef5c9f8171f..8f2317fc96375 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -2356,6 +2356,53 @@ type workspaceData struct { allowRenames bool } +// @Summary Completely clears the workspace's user and group ACLs. +// @ID completely-clears-the-workspaces-user-and-group-acls +// @Security CoderSessionToken +// @Tags Workspaces +// @Param workspace path string true "Workspace ID" format(uuid) +// @Success 204 +// @Router /workspaces/{workspace}/acl [delete] +func (api *API) deleteWorkspaceACL(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + workspace = httpmw.WorkspaceParam(r) + auditor = api.Auditor.Load() + aReq, commitAuditor = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + OrganizationID: workspace.OrganizationID, + }) + ) + + defer commitAuditor() + aReq.Old = workspace.WorkspaceTable() + + err := api.Database.InTx(func(tx database.Store) error { + err := tx.DeleteWorkspaceACLByID(ctx, workspace.ID) + if err != nil { + return xerrors.Errorf("delete workspace 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() + + httpapi.Write(ctx, rw, http.StatusNoContent, nil) +} + // workspacesData only returns the data the caller can access. If the caller // does not have the correct perms to read a given template, the template will // not be returned. diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 4beebc9d1337c..6045745debb3d 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -4914,6 +4914,79 @@ func TestUpdateWorkspaceACL(t *testing.T) { }) } +func TestDeleteWorkspaceACL(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + + t.Run("WorkspaceOwnerCanDelete", func(t *testing.T) { + t.Parallel() + + var ( + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: dv, + }) + admin = coderdtest.CreateFirstUser(t, client) + workspaceOwnerClient, workspaceOwner = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + _, toShareWithUser = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + workspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: admin.OrganizationID, + }).Do().Workspace + ) + + ctx := testutil.Context(t, testutil.WaitMedium) + + err := workspaceOwnerClient.UpdateWorkspaceACL(ctx, workspace.ID, codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + toShareWithUser.ID.String(): codersdk.WorkspaceRoleUse, + }, + }) + require.NoError(t, err) + + err = workspaceOwnerClient.DeleteWorkspaceACL(ctx, workspace.ID) + require.NoError(t, err) + + acl, err := workspaceOwnerClient.WorkspaceACL(ctx, workspace.ID) + require.NoError(t, err) + require.Empty(t, acl.Users) + }) + + t.Run("SharedUsersCannot", func(t *testing.T) { + t.Parallel() + + var ( + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: dv, + }) + admin = coderdtest.CreateFirstUser(t, client) + workspaceOwnerClient, workspaceOwner = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + sharedUseClient, toShareWithUser = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + workspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: admin.OrganizationID, + }).Do().Workspace + ) + + ctx := testutil.Context(t, testutil.WaitMedium) + + err := workspaceOwnerClient.UpdateWorkspaceACL(ctx, workspace.ID, codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + toShareWithUser.ID.String(): codersdk.WorkspaceRoleUse, + }, + }) + require.NoError(t, err) + + err = sharedUseClient.DeleteWorkspaceACL(ctx, workspace.ID) + assert.Error(t, err) + + acl, err := workspaceOwnerClient.WorkspaceACL(ctx, workspace.ID) + require.NoError(t, err) + require.Equal(t, acl.Users[0].ID, toShareWithUser.ID) + }) +} + func TestWorkspaceCreateWithImplicitPreset(t *testing.T) { t.Parallel() diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index a38cca8bbe9a9..a006595f0eba6 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -722,6 +722,18 @@ func (c *Client) UpdateWorkspaceACL(ctx context.Context, workspaceID uuid.UUID, return nil } +func (c *Client) DeleteWorkspaceACL(ctx context.Context, workspaceID uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/workspaces/%s/acl", workspaceID), nil) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + // ExternalAgentCredentials contains the credentials needed for an external agent to connect to Coder. type ExternalAgentCredentials struct { Command string `json:"command"` diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index 23ff5f01450b0..455fefcb57749 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -1598,6 +1598,32 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/acl \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Completely clears the workspace's user and group ACLs + +### Code samples + +```shell +# Example request using curl +curl -X DELETE http://coder-server:8080/api/v2/workspaces/{workspace}/acl \ + -H 'Coder-Session-Token: API_KEY' +``` + +`DELETE /workspaces/{workspace}/acl` + +### Parameters + +| Name | In | Type | Required | Description | +|-------------|------|--------------|----------|--------------| +| `workspace` | path | string(uuid) | true | Workspace ID | + +### 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 ACL ### Code samples diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 0943fd9077868..745af8df23c7f 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -4102,3 +4102,99 @@ func TestUpdateWorkspaceACL(t *testing.T) { require.Equal(t, cerr.Validations[1].Field, "user_roles") }) } + +func TestDeleteWorkspaceACL(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + + t.Run("WorkspaceOwnerCanDelete_Groups", func(t *testing.T) { + t.Parallel() + + var ( + client, db, admin = coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }, + }) + workspaceOwnerClient, workspaceOwner = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID, rbac.ScopedRoleOrgAuditor(admin.OrganizationID)) + workspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: admin.OrganizationID, + }).Do().Workspace + ) + + ctx := testutil.Context(t, testutil.WaitMedium) + + group, err := client.CreateGroup(ctx, admin.OrganizationID, codersdk.CreateGroupRequest{ + Name: "wibble", + }) + require.NoError(t, err) + err = workspaceOwnerClient.UpdateWorkspaceACL(ctx, workspace.ID, codersdk.UpdateWorkspaceACL{ + GroupRoles: map[string]codersdk.WorkspaceRole{ + group.ID.String(): codersdk.WorkspaceRoleUse, + }, + }) + require.NoError(t, err) + + err = workspaceOwnerClient.DeleteWorkspaceACL(ctx, workspace.ID) + require.NoError(t, err) + + acl, err := workspaceOwnerClient.WorkspaceACL(ctx, workspace.ID) + require.NoError(t, err) + require.Empty(t, acl.Groups) + }) + + t.Run("SharedGroupUsersCannotDelete", func(t *testing.T) { + t.Parallel() + + var ( + client, db, admin = coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }, + }) + workspaceOwnerClient, workspaceOwner = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID, rbac.ScopedRoleOrgAuditor(admin.OrganizationID)) + workspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: admin.OrganizationID, + }).Do().Workspace + sharedClient, toShareWithUser = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + ) + + ctx := testutil.Context(t, testutil.WaitMedium) + + group, err := client.CreateGroup(ctx, admin.OrganizationID, codersdk.CreateGroupRequest{ + Name: "wibble", + }) + require.NoError(t, err) + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{toShareWithUser.ID.String()}, + }) + require.NoError(t, err) + err = workspaceOwnerClient.UpdateWorkspaceACL(ctx, workspace.ID, codersdk.UpdateWorkspaceACL{ + GroupRoles: map[string]codersdk.WorkspaceRole{ + group.ID.String(): codersdk.WorkspaceRoleUse, + }, + }) + require.NoError(t, err) + + err = sharedClient.DeleteWorkspaceACL(ctx, workspace.ID) + require.Error(t, err) + + acl, err := workspaceOwnerClient.WorkspaceACL(ctx, workspace.ID) + require.NoError(t, err) + require.Equal(t, acl.Groups[0].ID, group.ID) + }) +}