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"