diff --git a/cli/organizationsettings.go b/cli/organizationsettings.go index b2934ef006..27cafa7d14 100644 --- a/cli/organizationsettings.go +++ b/cli/organizationsettings.go @@ -65,6 +65,22 @@ func (r *RootCmd) organizationSettings(orgContext *OrganizationContext) *serpent return cli.OrganizationIDPSyncSettings(ctx) }, }, + { + Name: "workspace-sharing", + Aliases: []string{"workspacesharing"}, + Short: "Workspace sharing settings for the organization.", + Patch: func(ctx context.Context, cli *codersdk.Client, org uuid.UUID, input json.RawMessage) (any, error) { + var req codersdk.WorkspaceSharingSettings + err := json.Unmarshal(input, &req) + if err != nil { + return nil, xerrors.Errorf("unmarshalling workspace sharing settings: %w", err) + } + return cli.PatchWorkspaceSharingSettings(ctx, org.String(), req) + }, + Fetch: func(ctx context.Context, cli *codersdk.Client, org uuid.UUID) (any, error) { + return cli.WorkspaceSharingSettings(ctx, org.String()) + }, + }, } cmd := &serpent.Command{ Use: "settings", diff --git a/cli/testdata/coder_organizations_settings_set_--help.golden b/cli/testdata/coder_organizations_settings_set_--help.golden index a6554785f3..84322072cc 100644 --- a/cli/testdata/coder_organizations_settings_set_--help.golden +++ b/cli/testdata/coder_organizations_settings_set_--help.golden @@ -15,6 +15,7 @@ SUBCOMMANDS: memberships from an IdP. role-sync Role sync settings to sync organization roles from an IdP. + workspace-sharing Workspace sharing settings for the organization. ——— Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_organizations_settings_set_--help_--help.golden b/cli/testdata/coder_organizations_settings_set_--help_--help.golden index a6554785f3..84322072cc 100644 --- a/cli/testdata/coder_organizations_settings_set_--help_--help.golden +++ b/cli/testdata/coder_organizations_settings_set_--help_--help.golden @@ -15,6 +15,7 @@ SUBCOMMANDS: memberships from an IdP. role-sync Role sync settings to sync organization roles from an IdP. + workspace-sharing Workspace sharing settings for the organization. ——— Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_organizations_settings_show_--help.golden b/cli/testdata/coder_organizations_settings_show_--help.golden index da8ccb18c1..296936487d 100644 --- a/cli/testdata/coder_organizations_settings_show_--help.golden +++ b/cli/testdata/coder_organizations_settings_show_--help.golden @@ -15,6 +15,7 @@ SUBCOMMANDS: memberships from an IdP. role-sync Role sync settings to sync organization roles from an IdP. + workspace-sharing Workspace sharing settings for the organization. ——— Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_organizations_settings_show_--help_--help.golden b/cli/testdata/coder_organizations_settings_show_--help_--help.golden index da8ccb18c1..296936487d 100644 --- a/cli/testdata/coder_organizations_settings_show_--help_--help.golden +++ b/cli/testdata/coder_organizations_settings_show_--help_--help.golden @@ -15,6 +15,7 @@ SUBCOMMANDS: memberships from an IdP. role-sync Role sync settings to sync organization roles from an IdP. + workspace-sharing Workspace sharing settings for the organization. ——— Run `coder --help` for a list of global options. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7fd739dd7b..576e942ec5 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -4566,6 +4566,86 @@ const docTemplate = `{ } } }, + "/organizations/{organization}/settings/workspace-sharing": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get workspace sharing settings for organization", + "operationId": "get-workspace-sharing-settings-for-organization", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceSharingSettings" + } + } + } + }, + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Update workspace sharing settings for organization", + "operationId": "update-workspace-sharing-settings-for-organization", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "description": "Workspace sharing settings", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceSharingSettings" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceSharingSettings" + } + } + } + } + }, "/organizations/{organization}/templates": { "get": { "security": [ @@ -21454,6 +21534,14 @@ const docTemplate = `{ "WorkspaceRoleDeleted" ] }, + "codersdk.WorkspaceSharingSettings": { + "type": "object", + "properties": { + "sharing_disabled": { + "type": "boolean" + } + } + }, "codersdk.WorkspaceStatus": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 3f750f6b4e..1dbab4fa34 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4036,6 +4036,76 @@ } } }, + "/organizations/{organization}/settings/workspace-sharing": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get workspace sharing settings for organization", + "operationId": "get-workspace-sharing-settings-for-organization", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceSharingSettings" + } + } + } + }, + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Update workspace sharing settings for organization", + "operationId": "update-workspace-sharing-settings-for-organization", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "description": "Workspace sharing settings", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceSharingSettings" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceSharingSettings" + } + } + } + } + }, "/organizations/{organization}/templates": { "get": { "security": [ @@ -19733,6 +19803,14 @@ "WorkspaceRoleDeleted" ] }, + "codersdk.WorkspaceSharingSettings": { + "type": "object", + "properties": { + "sharing_disabled": { + "type": "boolean" + } + } + }, "codersdk.WorkspaceStatus": { "type": "string", "enum": [ diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 538a440a47..f9921b44f3 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1965,6 +1965,14 @@ func (q *querier) DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) erro return fetchAndExec(q.log, q.auth, policy.ActionShare, fetch, q.db.DeleteWorkspaceACLByID)(ctx, id) } +func (q *querier) DeleteWorkspaceACLsByOrganization(ctx context.Context, organizationID uuid.UUID) error { + // This is a system-only function. + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { + return err + } + return q.db.DeleteWorkspaceACLsByOrganization(ctx, organizationID) +} + func (q *querier) DeleteWorkspaceAgentPortShare(ctx context.Context, arg database.DeleteWorkspaceAgentPortShareParams) error { w, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID) if err != nil { @@ -3592,7 +3600,7 @@ func (q *querier) GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (databa if err != nil { return database.GetWorkspaceACLByIDRow{}, err } - if err := q.authorizeContext(ctx, policy.ActionShare, workspace); err != nil { + if err := q.authorizeContext(ctx, policy.ActionRead, workspace); err != nil { return database.GetWorkspaceACLByIDRow{}, err } return q.db.GetWorkspaceACLByID(ctx, id) @@ -5099,6 +5107,13 @@ func (q *querier) UpdateOrganizationDeletedByID(ctx context.Context, arg databas return deleteQ(q.log, q.auth, q.db.GetOrganizationByID, deleteF)(ctx, arg.ID) } +func (q *querier) UpdateOrganizationWorkspaceSharingSettings(ctx context.Context, arg database.UpdateOrganizationWorkspaceSharingSettingsParams) (database.Organization, error) { + fetch := func(ctx context.Context, arg database.UpdateOrganizationWorkspaceSharingSettingsParams) (database.Organization, error) { + return q.db.GetOrganizationByID(ctx, arg.ID) + } + return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateOrganizationWorkspaceSharingSettings)(ctx, arg) +} + func (q *querier) UpdatePrebuildProvisionerJobWithCancel(ctx context.Context, arg database.UpdatePrebuildProvisionerJobWithCancelParams) ([]database.UpdatePrebuildProvisionerJobWithCancelRow, error) { // Prebuild operation for canceling pending prebuild jobs from non-active template versions if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourcePrebuiltWorkspace); err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 2921704894..5c0bb1f3af 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -880,6 +880,16 @@ func (s *MethodTestSuite) TestOrganization() { dbm.EXPECT().InsertOrganization(gomock.Any(), arg).Return(database.Organization{ID: arg.ID, Name: arg.Name}, nil).AnyTimes() check.Args(arg).Asserts(rbac.ResourceOrganization, policy.ActionCreate) })) + s.Run("UpdateOrganizationWorkspaceSharingSettings", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + org := testutil.Fake(s.T(), faker, database.Organization{}) + arg := database.UpdateOrganizationWorkspaceSharingSettingsParams{ + ID: org.ID, + WorkspaceSharingDisabled: true, + } + dbm.EXPECT().GetOrganizationByID(gomock.Any(), org.ID).Return(org, nil).AnyTimes() + dbm.EXPECT().UpdateOrganizationWorkspaceSharingSettings(gomock.Any(), arg).Return(org, nil).AnyTimes() + check.Args(arg).Asserts(org, policy.ActionUpdate).Returns(org) + })) s.Run("InsertOrganizationMember", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { o := testutil.Fake(s.T(), faker, database.Organization{}) u := testutil.Fake(s.T(), faker, database.User{}) @@ -1784,7 +1794,7 @@ func (s *MethodTestSuite) TestWorkspace() { ws := testutil.Fake(s.T(), faker, database.Workspace{}) dbM.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes() dbM.EXPECT().GetWorkspaceACLByID(gomock.Any(), ws.ID).Return(database.GetWorkspaceACLByIDRow{}, nil).AnyTimes() - check.Args(ws.ID).Asserts(ws, policy.ActionShare) + check.Args(ws.ID).Asserts(ws, policy.ActionRead) })) s.Run("UpdateWorkspaceACLByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { w := testutil.Fake(s.T(), faker, database.Workspace{}) @@ -1799,6 +1809,11 @@ func (s *MethodTestSuite) TestWorkspace() { dbm.EXPECT().DeleteWorkspaceACLByID(gomock.Any(), w.ID).Return(nil).AnyTimes() check.Args(w.ID).Asserts(w, policy.ActionShare) })) + s.Run("DeleteWorkspaceACLsByOrganization", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + orgID := uuid.New() + dbm.EXPECT().DeleteWorkspaceACLsByOrganization(gomock.Any(), orgID).Return(nil).AnyTimes() + check.Args(orgID).Asserts(rbac.ResourceSystem, 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 8908910864..c49d141f33 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -560,6 +560,13 @@ func (m queryMetricsStore) DeleteWorkspaceACLByID(ctx context.Context, id uuid.U return r0 } +func (m queryMetricsStore) DeleteWorkspaceACLsByOrganization(ctx context.Context, organizationID uuid.UUID) error { + start := time.Now() + r0 := m.s.DeleteWorkspaceACLsByOrganization(ctx, organizationID) + m.queryLatencies.WithLabelValues("DeleteWorkspaceACLsByOrganization").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) @@ -3094,6 +3101,13 @@ func (m queryMetricsStore) UpdateOrganizationDeletedByID(ctx context.Context, ar return r0 } +func (m queryMetricsStore) UpdateOrganizationWorkspaceSharingSettings(ctx context.Context, arg database.UpdateOrganizationWorkspaceSharingSettingsParams) (database.Organization, error) { + start := time.Now() + r0, r1 := m.s.UpdateOrganizationWorkspaceSharingSettings(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateOrganizationWorkspaceSharingSettings").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) UpdatePrebuildProvisionerJobWithCancel(ctx context.Context, arg database.UpdatePrebuildProvisionerJobWithCancelParams) ([]database.UpdatePrebuildProvisionerJobWithCancelRow, error) { start := time.Now() r0, r1 := m.s.UpdatePrebuildProvisionerJobWithCancel(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index f25e91e90c..87f533094a 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1055,6 +1055,20 @@ func (mr *MockStoreMockRecorder) DeleteWorkspaceACLByID(ctx, id any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWorkspaceACLByID", reflect.TypeOf((*MockStore)(nil).DeleteWorkspaceACLByID), ctx, id) } +// DeleteWorkspaceACLsByOrganization mocks base method. +func (m *MockStore) DeleteWorkspaceACLsByOrganization(ctx context.Context, organizationID uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteWorkspaceACLsByOrganization", ctx, organizationID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteWorkspaceACLsByOrganization indicates an expected call of DeleteWorkspaceACLsByOrganization. +func (mr *MockStoreMockRecorder) DeleteWorkspaceACLsByOrganization(ctx, organizationID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWorkspaceACLsByOrganization", reflect.TypeOf((*MockStore)(nil).DeleteWorkspaceACLsByOrganization), ctx, organizationID) +} + // DeleteWorkspaceAgentPortShare mocks base method. func (m *MockStore) DeleteWorkspaceAgentPortShare(ctx context.Context, arg database.DeleteWorkspaceAgentPortShareParams) error { m.ctrl.T.Helper() @@ -6645,6 +6659,21 @@ func (mr *MockStoreMockRecorder) UpdateOrganizationDeletedByID(ctx, arg any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOrganizationDeletedByID", reflect.TypeOf((*MockStore)(nil).UpdateOrganizationDeletedByID), ctx, arg) } +// UpdateOrganizationWorkspaceSharingSettings mocks base method. +func (m *MockStore) UpdateOrganizationWorkspaceSharingSettings(ctx context.Context, arg database.UpdateOrganizationWorkspaceSharingSettingsParams) (database.Organization, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateOrganizationWorkspaceSharingSettings", ctx, arg) + ret0, _ := ret[0].(database.Organization) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateOrganizationWorkspaceSharingSettings indicates an expected call of UpdateOrganizationWorkspaceSharingSettings. +func (mr *MockStoreMockRecorder) UpdateOrganizationWorkspaceSharingSettings(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOrganizationWorkspaceSharingSettings", reflect.TypeOf((*MockStore)(nil).UpdateOrganizationWorkspaceSharingSettings), ctx, arg) +} + // UpdatePrebuildProvisionerJobWithCancel mocks base method. func (m *MockStore) UpdatePrebuildProvisionerJobWithCancel(ctx context.Context, arg database.UpdatePrebuildProvisionerJobWithCancelParams) ([]database.UpdatePrebuildProvisionerJobWithCancelRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 7202d22f3d..571a3b3f6c 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -139,6 +139,7 @@ type sqlcQuerier interface { DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg DeleteWebpushSubscriptionByUserIDAndEndpointParams) error DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) error + DeleteWorkspaceACLsByOrganization(ctx context.Context, organizationID 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 @@ -677,6 +678,7 @@ type sqlcQuerier interface { UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg UpdateOAuth2ProviderAppSecretByIDParams) (OAuth2ProviderAppSecret, error) UpdateOrganization(ctx context.Context, arg UpdateOrganizationParams) (Organization, error) UpdateOrganizationDeletedByID(ctx context.Context, arg UpdateOrganizationDeletedByIDParams) error + UpdateOrganizationWorkspaceSharingSettings(ctx context.Context, arg UpdateOrganizationWorkspaceSharingSettingsParams) (Organization, error) // Cancels all pending provisioner jobs for prebuilt workspaces on a specific preset from an // inactive template version. // This is an optimization to clean up stale pending jobs. diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index ac404541f1..0ca1fcd465 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -2304,6 +2304,94 @@ func TestDeleteCustomRoleDoesNotDeleteSystemRole(t *testing.T) { require.True(t, roles[0].IsSystem) } +func TestUpdateOrganizationWorkspaceSharingSettings(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + + ctx := testutil.Context(t, testutil.WaitShort) + + updated, err := db.UpdateOrganizationWorkspaceSharingSettings(ctx, database.UpdateOrganizationWorkspaceSharingSettingsParams{ + ID: org.ID, + WorkspaceSharingDisabled: true, + UpdatedAt: dbtime.Now(), + }) + require.NoError(t, err) + require.True(t, updated.WorkspaceSharingDisabled) + + got, err := db.GetOrganizationByID(ctx, org.ID) + require.NoError(t, err) + require.True(t, got.WorkspaceSharingDisabled) +} + +func TestDeleteWorkspaceACLsByOrganization(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + org1 := dbgen.Organization(t, db, database.Organization{}) + org2 := dbgen.Organization(t, db, database.Organization{}) + + owner1 := dbgen.User(t, db, database.User{}) + owner2 := dbgen.User(t, db, database.User{}) + sharedUser := dbgen.User(t, db, database.User{}) + sharedGroup := dbgen.Group(t, db, database.Group{ + OrganizationID: org1.ID, + }) + + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: org1.ID, + UserID: owner1.ID, + }) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: org2.ID, + UserID: owner2.ID, + }) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: org1.ID, + UserID: sharedUser.ID, + }) + + ws1 := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: owner1.ID, + OrganizationID: org1.ID, + UserACL: database.WorkspaceACL{ + sharedUser.ID.String(): { + Permissions: []policy.Action{policy.ActionRead}, + }, + }, + GroupACL: database.WorkspaceACL{ + sharedGroup.ID.String(): { + Permissions: []policy.Action{policy.ActionRead}, + }, + }, + }).Do().Workspace + + ws2 := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: owner2.ID, + OrganizationID: org2.ID, + UserACL: database.WorkspaceACL{ + uuid.NewString(): { + Permissions: []policy.Action{policy.ActionRead}, + }, + }, + }).Do().Workspace + + ctx := testutil.Context(t, testutil.WaitShort) + + err := db.DeleteWorkspaceACLsByOrganization(ctx, org1.ID) + require.NoError(t, err) + + got1, err := db.GetWorkspaceByID(ctx, ws1.ID) + require.NoError(t, err) + require.Empty(t, got1.UserACL) + require.Empty(t, got1.GroupACL) + + got2, err := db.GetWorkspaceByID(ctx, ws2.ID) + require.NoError(t, err) + require.NotEmpty(t, got2.UserACL) +} + func TestAuthorizedAuditLogs(t *testing.T) { t.Parallel() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f08e50b333..b4bbc35942 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8197,6 +8197,41 @@ func (q *sqlQuerier) UpdateOrganizationDeletedByID(ctx context.Context, arg Upda return err } +const updateOrganizationWorkspaceSharingSettings = `-- name: UpdateOrganizationWorkspaceSharingSettings :one +UPDATE + organizations +SET + workspace_sharing_disabled = $1, + updated_at = $2 +WHERE + id = $3 +RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, workspace_sharing_disabled +` + +type UpdateOrganizationWorkspaceSharingSettingsParams struct { + WorkspaceSharingDisabled bool `db:"workspace_sharing_disabled" json:"workspace_sharing_disabled"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateOrganizationWorkspaceSharingSettings(ctx context.Context, arg UpdateOrganizationWorkspaceSharingSettingsParams) (Organization, error) { + row := q.db.QueryRowContext(ctx, updateOrganizationWorkspaceSharingSettings, arg.WorkspaceSharingDisabled, arg.UpdatedAt, arg.ID) + var i Organization + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + &i.IsDefault, + &i.DisplayName, + &i.Icon, + &i.Deleted, + &i.WorkspaceSharingDisabled, + ) + return i, err +} + const getParameterSchemasByJobID = `-- name: GetParameterSchemasByJobID :many SELECT id, created_at, job_id, name, description, default_source_scheme, default_source_value, allow_override_source, default_destination_scheme, allow_override_destination, default_refresh, redisplay_value, validation_error, validation_condition, validation_type_system, validation_value_type, index @@ -22151,6 +22186,21 @@ func (q *sqlQuerier) DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) e return err } +const deleteWorkspaceACLsByOrganization = `-- name: DeleteWorkspaceACLsByOrganization :exec +UPDATE + workspaces +SET + group_acl = '{}'::jsonb, + user_acl = '{}'::jsonb +WHERE + organization_id = $1 +` + +func (q *sqlQuerier) DeleteWorkspaceACLsByOrganization(ctx context.Context, organizationID uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteWorkspaceACLsByOrganization, organizationID) + return err +} + const favoriteWorkspace = `-- name: FavoriteWorkspace :exec UPDATE workspaces SET favorite = true WHERE id = $1 ` diff --git a/coderd/database/queries/organizations.sql b/coderd/database/queries/organizations.sql index 89a4a7bcfc..c0e0de92d6 100644 --- a/coderd/database/queries/organizations.sql +++ b/coderd/database/queries/organizations.sql @@ -143,3 +143,13 @@ WHERE id = @id AND is_default = false; +-- name: UpdateOrganizationWorkspaceSharingSettings :one +UPDATE + organizations +SET + workspace_sharing_disabled = @workspace_sharing_disabled, + updated_at = @updated_at +WHERE + id = @id +RETURNING *; + diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index c6185fa5d8..c410281870 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -947,6 +947,15 @@ SET WHERE id = @id; +-- name: DeleteWorkspaceACLsByOrganization :exec +UPDATE + workspaces +SET + group_acl = '{}'::jsonb, + user_acl = '{}'::jsonb +WHERE + organization_id = @organization_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 504ab72755..d368ce1f8f 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -2344,6 +2344,10 @@ func (api *API) patchWorkspaceACL(rw http.ResponseWriter, r *http.Request) { defer commitAudit() aReq.Old = workspace.WorkspaceTable() + if !api.allowWorkspaceSharing(ctx, rw, workspace.OrganizationID) { + return + } + var req codersdk.UpdateWorkspaceACL if !httpapi.Read(ctx, rw, r, &req) { return @@ -2440,6 +2444,10 @@ func (api *API) deleteWorkspaceACL(rw http.ResponseWriter, r *http.Request) { defer commitAuditor() aReq.Old = workspace.WorkspaceTable() + if !api.allowWorkspaceSharing(ctx, rw, workspace.OrganizationID) { + return + } + err := api.Database.InTx(func(tx database.Store) error { err := tx.DeleteWorkspaceACLByID(ctx, workspace.ID) if err != nil { @@ -2463,6 +2471,27 @@ func (api *API) deleteWorkspaceACL(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusNoContent, nil) } +// allowWorkspaceSharing enforces the workspace-sharing gate for an +// organization. It writes an HTTP error response and returns false if +// sharing is disabled or the org lookup fails; otherwise it returns +// true. +func (api *API) allowWorkspaceSharing(ctx context.Context, rw http.ResponseWriter, organizationID uuid.UUID) bool { + //nolint:gocritic // Use system context so this check doesn’t + // depend on the caller having organization:read. + org, err := api.Database.GetOrganizationByID(dbauthz.AsSystemRestricted(ctx), organizationID) + if err != nil { + httpapi.InternalServerError(rw, err) + return false + } + if org.WorkspaceSharingDisabled { + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "Workspace sharing is disabled for this organization.", + }) + return false + } + return true +} + // 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 6171ea2c78..deb7a6b9cb 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -5266,7 +5266,66 @@ func TestDeleteWorkspaceACL(t *testing.T) { }) } -// nolint:tparallel,paralleltest // Subtests modify package global. +// `use`-role shares are granted `workspace:read` via the workspace RBAC ACL +// list, so they should be able to read the ACL. +// +//nolint:tparallel,paralleltest // Test modifies a package global (rbac.workspaceACLDisabled). +func TestWorkspaceReadCanListACL(t *testing.T) { + // Be defensive by saving/restoring the modified package global. + prevWorkspaceACLDisabled := rbac.WorkspaceACLDisabled() + rbac.SetWorkspaceACLDisabled(false) + t.Cleanup(func() { rbac.SetWorkspaceACLDisabled(prevWorkspaceACLDisabled) }) + + var ( + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t, func(dv *codersdk.DeploymentValues) { + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + }), + }) + admin = coderdtest.CreateFirstUser(t, client) + workspaceOwnerClient, workspaceOwner = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + sharedUserClientA, sharedUserA = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + _, sharedUserB = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + sharedGroup = dbgen.Group(t, db, database.Group{OrganizationID: 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{ + sharedUserA.ID.String(): codersdk.WorkspaceRoleUse, + sharedUserB.ID.String(): codersdk.WorkspaceRoleAdmin, + }, + GroupRoles: map[string]codersdk.WorkspaceRole{ + sharedGroup.ID.String(): codersdk.WorkspaceRoleUse, + }, + }) + require.NoError(t, err) + + acl, err := sharedUserClientA.WorkspaceACL(ctx, workspace.ID) + require.NoError(t, err) + require.Len(t, acl.Users, 2) + require.Len(t, acl.Groups, 1) + + gotRoles := make(map[uuid.UUID]codersdk.WorkspaceRole, len(acl.Users)) + for _, u := range acl.Users { + gotRoles[u.ID] = u.Role + } + require.Equal(t, codersdk.WorkspaceRoleUse, gotRoles[sharedUserA.ID]) + require.Equal(t, codersdk.WorkspaceRoleAdmin, gotRoles[sharedUserB.ID]) + + gotGroupRoles := make(map[uuid.UUID]codersdk.WorkspaceRole, len(acl.Groups)) + for _, g := range acl.Groups { + gotGroupRoles[g.ID] = g.Role + } + require.Equal(t, codersdk.WorkspaceRoleUse, gotGroupRoles[sharedGroup.ID]) +} + +// nolint:tparallel,paralleltest // Subtests modify a package global (rbac.workspaceACLDisabled). func TestWorkspaceSharingDisabled(t *testing.T) { t.Run("CanAccessWhenEnabled", func(t *testing.T) { var ( diff --git a/codersdk/workspacesharing.go b/codersdk/workspacesharing.go new file mode 100644 index 0000000000..3912c3dc0b --- /dev/null +++ b/codersdk/workspacesharing.go @@ -0,0 +1,43 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// WorkspaceSharingSettings represents workspace sharing settings for an organization. +type WorkspaceSharingSettings struct { + SharingDisabled bool `json:"sharing_disabled"` +} + +// WorkspaceSharingSettings retrieves the workspace sharing settings for an organization. +func (c *Client) WorkspaceSharingSettings(ctx context.Context, orgID string) (WorkspaceSharingSettings, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/settings/workspace-sharing", orgID), nil) + if err != nil { + return WorkspaceSharingSettings{}, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return WorkspaceSharingSettings{}, ReadBodyAsError(res) + } + var resp WorkspaceSharingSettings + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// PatchWorkspaceSharingSettings modifies the workspace sharing settings for an organization. +func (c *Client) PatchWorkspaceSharingSettings(ctx context.Context, orgID string, req WorkspaceSharingSettings) (WorkspaceSharingSettings, error) { + res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/organizations/%s/settings/workspace-sharing", orgID), req) + if err != nil { + return WorkspaceSharingSettings{}, err + } + + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return WorkspaceSharingSettings{}, ReadBodyAsError(res) + } + var resp WorkspaceSharingSettings + return resp, json.NewDecoder(res.Body).Decode(&resp) +} diff --git a/docs/manifest.json b/docs/manifest.json index 06a99a52ea..1004ceca22 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1606,6 +1606,11 @@ "description": "Role sync settings to sync organization roles from an IdP.", "path": "reference/cli/organizations_settings_set_role-sync.md" }, + { + "title": "organizations settings set workspace-sharing", + "description": "Workspace sharing settings for the organization.", + "path": "reference/cli/organizations_settings_set_workspace-sharing.md" + }, { "title": "organizations settings show", "description": "Outputs specified organization setting.", @@ -1626,6 +1631,11 @@ "description": "Role sync settings to sync organization roles from an IdP.", "path": "reference/cli/organizations_settings_show_role-sync.md" }, + { + "title": "organizations settings show workspace-sharing", + "description": "Workspace sharing settings for the organization.", + "path": "reference/cli/organizations_settings_show_workspace-sharing.md" + }, { "title": "organizations show", "description": "Show the organization. Using \"selected\" will show the selected organization from the \"--org\" flag. Using \"me\" will show all organizations you are a member of.", diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 8590e4182c..b99868a259 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -2832,6 +2832,90 @@ curl -X PATCH http://coder-server:8080/api/v2/organizations/{organization}/setti To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get workspace sharing settings for organization + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/settings/workspace-sharing \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /organizations/{organization}/settings/workspace-sharing` + +### Parameters + +| Name | In | Type | Required | Description | +|----------------|------|--------------|----------|-----------------| +| `organization` | path | string(uuid) | true | Organization ID | + +### Example responses + +> 200 Response + +```json +{ + "sharing_disabled": true +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceSharingSettings](schemas.md#codersdkworkspacesharingsettings) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Update workspace sharing settings for organization + +### Code samples + +```shell +# Example request using curl +curl -X PATCH http://coder-server:8080/api/v2/organizations/{organization}/settings/workspace-sharing \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PATCH /organizations/{organization}/settings/workspace-sharing` + +> Body parameter + +```json +{ + "sharing_disabled": true +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|----------------|------|----------------------------------------------------------------------------------|----------|----------------------------| +| `organization` | path | string(uuid) | true | Organization ID | +| `body` | body | [codersdk.WorkspaceSharingSettings](schemas.md#codersdkworkspacesharingsettings) | true | Workspace sharing settings | + +### Example responses + +> 200 Response + +```json +{ + "sharing_disabled": true +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceSharingSettings](schemas.md#codersdkworkspacesharingsettings) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Fetch provisioner key details ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 91d5c8d598..010fc9dc3a 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -11671,6 +11671,20 @@ If the schedule is empty, the user will be updated to use the default schedule.| |--------------------| | ``, `admin`, `use` | +## codersdk.WorkspaceSharingSettings + +```json +{ + "sharing_disabled": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------------|---------|----------|--------------|-------------| +| `sharing_disabled` | boolean | false | | | + ## codersdk.WorkspaceStatus ```json diff --git a/docs/reference/cli/organizations_settings_set.md b/docs/reference/cli/organizations_settings_set.md index c7d0fd8f13..97eb8007c3 100644 --- a/docs/reference/cli/organizations_settings_set.md +++ b/docs/reference/cli/organizations_settings_set.md @@ -24,3 +24,4 @@ coder organizations settings set | [group-sync](./organizations_settings_set_group-sync.md) | Group sync settings to sync groups from an IdP. | | [role-sync](./organizations_settings_set_role-sync.md) | Role sync settings to sync organization roles from an IdP. | | [organization-sync](./organizations_settings_set_organization-sync.md) | Organization sync settings to sync organization memberships from an IdP. | +| [workspace-sharing](./organizations_settings_set_workspace-sharing.md) | Workspace sharing settings for the organization. | diff --git a/docs/reference/cli/organizations_settings_set_workspace-sharing.md b/docs/reference/cli/organizations_settings_set_workspace-sharing.md new file mode 100644 index 0000000000..579d2bbacd --- /dev/null +++ b/docs/reference/cli/organizations_settings_set_workspace-sharing.md @@ -0,0 +1,14 @@ + +# organizations settings set workspace-sharing + +Workspace sharing settings for the organization. + +Aliases: + +* workspacesharing + +## Usage + +```console +coder organizations settings set workspace-sharing +``` diff --git a/docs/reference/cli/organizations_settings_show.md b/docs/reference/cli/organizations_settings_show.md index 90dc642745..fdd3f00531 100644 --- a/docs/reference/cli/organizations_settings_show.md +++ b/docs/reference/cli/organizations_settings_show.md @@ -24,3 +24,4 @@ coder organizations settings show | [group-sync](./organizations_settings_show_group-sync.md) | Group sync settings to sync groups from an IdP. | | [role-sync](./organizations_settings_show_role-sync.md) | Role sync settings to sync organization roles from an IdP. | | [organization-sync](./organizations_settings_show_organization-sync.md) | Organization sync settings to sync organization memberships from an IdP. | +| [workspace-sharing](./organizations_settings_show_workspace-sharing.md) | Workspace sharing settings for the organization. | diff --git a/docs/reference/cli/organizations_settings_show_workspace-sharing.md b/docs/reference/cli/organizations_settings_show_workspace-sharing.md new file mode 100644 index 0000000000..9fbd7d1865 --- /dev/null +++ b/docs/reference/cli/organizations_settings_show_workspace-sharing.md @@ -0,0 +1,14 @@ + +# organizations settings show workspace-sharing + +Workspace sharing settings for the organization. + +Aliases: + +* workspacesharing + +## Usage + +```console +coder organizations settings show workspace-sharing +``` diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index b0b42faff9..14e67f70d5 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -361,6 +361,14 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Get("/idpsync/available-fields", api.organizationIDPSyncClaimFields) r.Get("/idpsync/field-values", api.organizationIDPSyncClaimFieldValues) + + r.Route("/workspace-sharing", func(r chi.Router) { + r.Use( + httpmw.RequireExperiment(api.AGPL.Experiments, codersdk.ExperimentWorkspaceSharing), + ) + r.Get("/", api.workspaceSharingSettings) + r.Patch("/", api.patchWorkspaceSharingSettings) + }) }) }) diff --git a/enterprise/coderd/workspacesharing.go b/enterprise/coderd/workspacesharing.go new file mode 100644 index 0000000000..e4814a9c8b --- /dev/null +++ b/enterprise/coderd/workspacesharing.go @@ -0,0 +1,141 @@ +package coderd + +import ( + "net/http" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/rbac/rolestore" + "github.com/coder/coder/v2/codersdk" +) + +// @Summary Get workspace sharing settings for organization +// @ID get-workspace-sharing-settings-for-organization +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param organization path string true "Organization ID" format(uuid) +// @Success 200 {object} codersdk.WorkspaceSharingSettings +// @Router /organizations/{organization}/settings/workspace-sharing [get] +func (api *API) workspaceSharingSettings(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + org := httpmw.OrganizationParam(r) + + if !api.Authorize(r, policy.ActionRead, org) { + httpapi.Forbidden(rw) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceSharingSettings{ + SharingDisabled: org.WorkspaceSharingDisabled, + }) +} + +// @Summary Update workspace sharing settings for organization +// @ID update-workspace-sharing-settings-for-organization +// @Security CoderSessionToken +// @Produce json +// @Accept json +// @Tags Enterprise +// @Param organization path string true "Organization ID" format(uuid) +// @Param request body codersdk.WorkspaceSharingSettings true "Workspace sharing settings" +// @Success 200 {object} codersdk.WorkspaceSharingSettings +// @Router /organizations/{organization}/settings/workspace-sharing [patch] +func (api *API) patchWorkspaceSharingSettings(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + org := httpmw.OrganizationParam(r) + auditor := *api.AGPL.Auditor.Load() + aReq, commitAudit := audit.InitRequest[database.Organization](rw, &audit.RequestParams{ + Audit: auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + OrganizationID: org.ID, + }) + aReq.Old = org + defer commitAudit() + + if !api.Authorize(r, policy.ActionUpdate, org) { + httpapi.Forbidden(rw) + return + } + + var req codersdk.WorkspaceSharingSettings + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + err := api.Database.InTx(func(tx database.Store) error { + //nolint:gocritic // System context required to look up and reconcile the + // organization-member system role; callers only need `organization:update` + sysCtx := dbauthz.AsSystemRestricted(ctx) + + // Serialize organization workspace-sharing updates with system role + // reconciliation across coderd instances (e.g. during rolling restarts). + // This prevents conflicting writes to the organization-member system role. + // TODO(geokat): Consider finer-grained locks as we add more system roles. + err := tx.AcquireLock(ctx, database.LockIDReconcileSystemRoles) + if err != nil { + return xerrors.Errorf("acquire system roles reconciliation lock: %w", err) + } + + org, err = tx.UpdateOrganizationWorkspaceSharingSettings(ctx, database.UpdateOrganizationWorkspaceSharingSettingsParams{ + ID: org.ID, + WorkspaceSharingDisabled: req.SharingDisabled, + UpdatedAt: dbtime.Now(), + }) + if err != nil { + return xerrors.Errorf("update organization workspace sharing settings: %w", err) + } + + role, err := database.ExpectOne(tx.CustomRoles(sysCtx, database.CustomRolesParams{ + LookupRoles: []database.NameOrganizationPair{ + { + Name: rbac.RoleOrgMember(), + OrganizationID: org.ID, + }, + }, + // Satisfy linter that requires all fields to be set. + OrganizationID: org.ID, + ExcludeOrgRoles: false, + IncludeSystemRoles: true, + })) + if err != nil { + return xerrors.Errorf("get organization-member role: %w", err) + } + + _, _, err = rolestore.ReconcileOrgMemberRole(sysCtx, tx, role, req.SharingDisabled) + if err != nil { + return xerrors.Errorf("reconcile organization-member role: %w", err) + } + + if req.SharingDisabled { + err = tx.DeleteWorkspaceACLsByOrganization(sysCtx, org.ID) + if err != nil { + return xerrors.Errorf("delete workspace ACLs by organization: %w", err) + } + } + + return nil + }, nil) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error updating workspace sharing settings.", + Detail: err.Error(), + }) + return + } + + aReq.New = org + httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceSharingSettings{ + SharingDisabled: org.WorkspaceSharingDisabled, + }) +} diff --git a/enterprise/coderd/workspacesharing_test.go b/enterprise/coderd/workspacesharing_test.go new file mode 100644 index 0000000000..9b02cf66d3 --- /dev/null +++ b/enterprise/coderd/workspacesharing_test.go @@ -0,0 +1,287 @@ +package coderd_test + +import ( + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" +) + +func TestWorkspaceSharingSettings(t *testing.T) { + t.Parallel() + + t.Run("DisabledDefaultsFalse", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + + client, first := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + + memberClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + settings, err := memberClient.WorkspaceSharingSettings(ctx, first.OrganizationID.String()) + require.NoError(t, err) + require.False(t, settings.SharingDisabled) + }) + + t.Run("DisabledTogglePersists", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + + client, first := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + + orgAdminClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.ScopedRoleOrgAdmin(first.OrganizationID)) + settings, err := orgAdminClient.PatchWorkspaceSharingSettings(ctx, first.OrganizationID.String(), codersdk.WorkspaceSharingSettings{ + SharingDisabled: true, + }) + require.NoError(t, err) + require.True(t, settings.SharingDisabled) + + settings, err = orgAdminClient.WorkspaceSharingSettings(ctx, first.OrganizationID.String()) + require.NoError(t, err) + require.True(t, settings.SharingDisabled) + + settings, err = orgAdminClient.PatchWorkspaceSharingSettings(ctx, first.OrganizationID.String(), codersdk.WorkspaceSharingSettings{ + SharingDisabled: false, + }) + require.NoError(t, err) + require.False(t, settings.SharingDisabled) + }) + + t.Run("UpdateAuthz", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + + client, first := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + + memberClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + _, err := memberClient.PatchWorkspaceSharingSettings(ctx, first.OrganizationID.String(), codersdk.WorkspaceSharingSettings{ + SharingDisabled: true, + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + }) + + t.Run("AuditLog", func(t *testing.T) { + t.Parallel() + + auditor := audit.NewMock() + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + + client, first := coderdenttest.New(t, &coderdenttest.Options{ + AuditLogging: true, + Options: &coderdtest.Options{ + DeploymentValues: dv, + Auditor: auditor, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAuditLog: 1, + }, + }, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + + orgAdminClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.ScopedRoleOrgAdmin(first.OrganizationID)) + auditor.ResetLogs() + _, err := orgAdminClient.PatchWorkspaceSharingSettings(ctx, first.OrganizationID.String(), codersdk.WorkspaceSharingSettings{ + SharingDisabled: true, + }) + require.NoError(t, err) + + require.Len(t, auditor.AuditLogs(), 1) + alog := auditor.AuditLogs()[0] + require.Equal(t, database.AuditActionWrite, alog.Action) + require.Equal(t, database.ResourceTypeOrganization, alog.ResourceType) + require.Equal(t, first.OrganizationID, alog.ResourceID) + }) + + t.Run("ExperimentDisabled", func(t *testing.T) { + t.Parallel() + + // Note: NOT setting the experiment flag. + client, first := coderdenttest.New(t, &coderdenttest.Options{}) + + ctx := testutil.Context(t, testutil.WaitMedium) + + memberClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + _, err := memberClient.WorkspaceSharingSettings(ctx, first.OrganizationID.String()) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + require.Contains(t, apiErr.Message, "requires enabling") + require.Contains(t, apiErr.Message, "workspace-sharing") + }) +} + +func TestWorkspaceSharingDisabled(t *testing.T) { + t.Parallel() + + t.Run("ACLEndpointsForbidden", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + + client, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + }) + + workspaceOwnerClient, workspaceOwner := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + ws := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: owner.OrganizationID, + }).Do().Workspace + + ctx := testutil.Context(t, testutil.WaitMedium) + + orgAdminClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID)) + _, err := orgAdminClient.PatchWorkspaceSharingSettings(ctx, owner.OrganizationID.String(), codersdk.WorkspaceSharingSettings{ + SharingDisabled: true, + }) + require.NoError(t, err) + + // Reading the ACL list remains allowed even when workspace sharing is + // disabled, but mutating it is forbidden. + _, err = workspaceOwnerClient.WorkspaceACL(ctx, ws.ID) + require.NoError(t, err) + + // We don't allow mutating the ACL. + assertSharingDisabled := func(t *testing.T, err error) { + t.Helper() + + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + require.Equal(t, "Workspace sharing is disabled for this organization.", apiErr.Message) + } + + err = workspaceOwnerClient.UpdateWorkspaceACL(ctx, ws.ID, codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + uuid.NewString(): codersdk.WorkspaceRoleUse, + }, + }) + assertSharingDisabled(t, err) + + err = workspaceOwnerClient.DeleteWorkspaceACL(ctx, ws.ID) + assertSharingDisabled(t, err) + }) + + t.Run("ACLsPurged", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + + client, db, owner := 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, owner.OrganizationID) + _, sharedUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + // Create a group to test group ACL purging. + group := coderdtest.CreateGroup(t, client, owner.OrganizationID, "test-group") + + ws := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: owner.OrganizationID, + }).Do().Workspace + + ctx := testutil.Context(t, testutil.WaitMedium) + + // Set both user and group ACLs. + err := workspaceOwnerClient.UpdateWorkspaceACL(ctx, ws.ID, codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + sharedUser.ID.String(): codersdk.WorkspaceRoleUse, + }, + GroupRoles: map[string]codersdk.WorkspaceRole{ + group.ID.String(): codersdk.WorkspaceRoleUse, + }, + }) + require.NoError(t, err) + + acl, err := workspaceOwnerClient.WorkspaceACL(ctx, ws.ID) + require.NoError(t, err) + require.Len(t, acl.Users, 1) + require.Equal(t, sharedUser.ID, acl.Users[0].ID) + require.Equal(t, codersdk.WorkspaceRoleUse, acl.Users[0].Role) + require.Len(t, acl.Groups, 1) + require.Equal(t, group.ID, acl.Groups[0].ID) + require.Equal(t, codersdk.WorkspaceRoleUse, acl.Groups[0].Role) + + orgAdminClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID)) + _, err = orgAdminClient.PatchWorkspaceSharingSettings(ctx, owner.OrganizationID.String(), codersdk.WorkspaceSharingSettings{ + SharingDisabled: true, + }) + require.NoError(t, err) + + _, err = orgAdminClient.PatchWorkspaceSharingSettings(ctx, owner.OrganizationID.String(), codersdk.WorkspaceSharingSettings{ + SharingDisabled: false, + }) + require.NoError(t, err) + + // Verify both user and group ACLs are purged. + acl, err = workspaceOwnerClient.WorkspaceACL(ctx, ws.ID) + require.NoError(t, err) + require.Empty(t, acl.Users) + require.Empty(t, acl.Groups) + + // Verify ACLs can be set again after re-enabling sharing. + err = workspaceOwnerClient.UpdateWorkspaceACL(ctx, ws.ID, codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + sharedUser.ID.String(): codersdk.WorkspaceRoleUse, + }, + }) + require.NoError(t, err) + acl, err = workspaceOwnerClient.WorkspaceACL(ctx, ws.ID) + require.NoError(t, err) + require.Len(t, acl.Users, 1) + require.Equal(t, sharedUser.ID, acl.Users[0].ID) + }) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 47cfa51fe8..f124b3132b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -6676,6 +6676,14 @@ export type WorkspaceRole = "admin" | "" | "use"; export const WorkspaceRoles: WorkspaceRole[] = ["admin", "", "use"]; +// From codersdk/workspacesharing.go +/** + * WorkspaceSharingSettings represents workspace sharing settings for an organization. + */ +export interface WorkspaceSharingSettings { + readonly sharing_disabled: boolean; +} + // From codersdk/workspacebuilds.go export type WorkspaceStatus = | "canceled"