From 4379230a276540734c09cbcb3c90e3cee62276cf Mon Sep 17 00:00:00 2001 From: George K Date: Tue, 9 Dec 2025 08:13:09 -0800 Subject: [PATCH] feat: add deployment-wide option to disable workspace sharing (#21172) Adds `--disable-workspace-sharing` option. Workspace sharing is disabled by not including user and group ACLs in the workspace RBAC object, which prevents ACL-based authz. Closes https://github.com/coder/internal/issues/1072 The commit also adds saving of workspace user/group ACLs in the test DB data generator. --- cli/testdata/coder_server_--help.golden | 7 ++ cli/testdata/server-config.yaml.golden | 6 ++ coderd/apidoc/docs.go | 3 + coderd/apidoc/swagger.json | 3 + coderd/coderd.go | 4 + coderd/database/dbgen/dbgen.go | 10 +++ coderd/database/modelmethods.go | 11 ++- coderd/database/modelmethods_internal_test.go | 39 ++++++++++ coderd/rbac/object.go | 16 ++++ coderd/workspaces_test.go | 73 +++++++++++++++++++ codersdk/deployment.go | 10 +++ docs/reference/api/general.md | 1 + docs/reference/api/schemas.md | 3 + docs/reference/cli/server.md | 10 +++ .../cli/testdata/coder_server_--help.golden | 7 ++ site/src/api/typesGenerated.ts | 1 + 16 files changed, 202 insertions(+), 2 deletions(-) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 37605fc610..aa318a5f85 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -46,6 +46,13 @@ OPTIONS: the workspace serves malicious JavaScript. This is recommended for security purposes if a --wildcard-access-url is configured. + --disable-workspace-sharing bool, $CODER_DISABLE_WORKSPACE_SHARING + Disable workspace sharing (requires the "workspace-sharing" experiment + to be enabled). Workspace ACL checking is disabled and only owners can + have ssh, apps and terminal access to workspaces. Access based on the + 'owner' role is also allowed unless disabled via + --disable-owner-workspace-access. + --swagger-enable bool, $CODER_SWAGGER_ENABLE Expose the swagger endpoint via /swagger. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 5ab7096264..a9e6058a3e 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -497,6 +497,12 @@ disablePathApps: false # workspaces. # (default: , type: bool) disableOwnerWorkspaceAccess: false +# Disable workspace sharing (requires the "workspace-sharing" experiment to be +# enabled). Workspace ACL checking is disabled and only owners can have ssh, apps +# and terminal access to workspaces. Access based on the 'owner' role is also +# allowed unless disabled via --disable-owner-workspace-access. +# (default: , type: bool) +disableWorkspaceSharing: false # These options change the behavior of how clients interact with the Coder. # Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI. client: diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d559851452..b8e3331ecd 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -14214,6 +14214,9 @@ const docTemplate = `{ "disable_path_apps": { "type": "boolean" }, + "disable_workspace_sharing": { + "type": "boolean" + }, "docs_url": { "$ref": "#/definitions/serpent.URL" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d7282711a7..396a704a06 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -12798,6 +12798,9 @@ "disable_path_apps": { "type": "boolean" }, + "disable_workspace_sharing": { + "type": "boolean" + }, "docs_url": { "$ref": "#/definitions/serpent.URL" }, diff --git a/coderd/coderd.go b/coderd/coderd.go index e79a2226ba..b356f372dc 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -333,6 +333,10 @@ func New(options *Options) *API { }) } + if options.DeploymentValues.DisableWorkspaceSharing { + rbac.SetWorkspaceACLDisabled(true) + } + if options.PrometheusRegistry == nil { options.PrometheusRegistry = prometheus.NewRegistry() } diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index de0a3b3845..faf4b7803f 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -439,6 +439,16 @@ func Workspace(t testing.TB, db database.Store, orig database.WorkspaceTable) da require.NoError(t, err, "set workspace as dormant") workspace.DormantAt = orig.DormantAt } + if len(orig.UserACL) > 0 || len(orig.GroupACL) > 0 { + err = db.UpdateWorkspaceACLByID(genCtx, database.UpdateWorkspaceACLByIDParams{ + ID: workspace.ID, + UserACL: orig.UserACL, + GroupACL: orig.GroupACL, + }) + require.NoError(t, err, "set workspace ACL") + workspace.UserACL = orig.UserACL + workspace.GroupACL = orig.GroupACL + } return workspace } diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 5e92f305e0..1bfeebfa69 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -430,9 +430,16 @@ func (w WorkspaceTable) RBACObject() rbac.Object { return w.DormantRBAC() } - return rbac.ResourceWorkspace.WithID(w.ID). + obj := rbac.ResourceWorkspace. + WithID(w.ID). InOrg(w.OrganizationID). - WithOwner(w.OwnerID.String()). + WithOwner(w.OwnerID.String()) + + if rbac.WorkspaceACLDisabled() { + return obj + } + + return obj. WithGroupACL(w.GroupACL.RBACACL()). WithACLUserList(w.UserACL.RBACACL()) } diff --git a/coderd/database/modelmethods_internal_test.go b/coderd/database/modelmethods_internal_test.go index 574d189206..27cbd916fa 100644 --- a/coderd/database/modelmethods_internal_test.go +++ b/coderd/database/modelmethods_internal_test.go @@ -143,6 +143,45 @@ func TestAPIKeyScopesExpand(t *testing.T) { }) } +//nolint:tparallel,paralleltest +func TestWorkspaceACLDisabled(t *testing.T) { + uid := uuid.NewString() + gid := uuid.NewString() + + ws := WorkspaceTable{ + ID: uuid.New(), + OrganizationID: uuid.New(), + OwnerID: uuid.New(), + UserACL: WorkspaceACL{ + uid: WorkspaceACLEntry{Permissions: []policy.Action{policy.ActionSSH}}, + }, + GroupACL: WorkspaceACL{ + gid: WorkspaceACLEntry{Permissions: []policy.Action{policy.ActionSSH}}, + }, + } + + t.Run("ACLsOmittedWhenDisabled", func(t *testing.T) { + rbac.SetWorkspaceACLDisabled(true) + t.Cleanup(func() { rbac.SetWorkspaceACLDisabled(false) }) + + obj := ws.RBACObject() + + require.Empty(t, obj.ACLUserList, "user ACLs should be empty when disabled") + require.Empty(t, obj.ACLGroupList, "group ACLs should be empty when disabled") + }) + + t.Run("ACLsIncludedWhenEnabled", func(t *testing.T) { + rbac.SetWorkspaceACLDisabled(false) + + obj := ws.RBACObject() + + require.NotEmpty(t, obj.ACLUserList, "user ACLs should be present when enabled") + require.NotEmpty(t, obj.ACLGroupList, "group ACLs should be present when enabled") + require.Contains(t, obj.ACLUserList, uid) + require.Contains(t, obj.ACLGroupList, gid) + }) +} + // Helpers func requirePermission(t *testing.T, s rbac.Scope, resource string, action policy.Action) { t.Helper() diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 9beef03dd8..476673a980 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -236,3 +236,19 @@ func (z Object) WithGroupACL(groups map[string][]policy.Action) Object { AnyOrgOwner: z.AnyOrgOwner, } } + +// TODO(geokat): similar to builtInRoles, this should ideally be +// scoped to a coderd rather than a global. +var workspaceACLDisabled bool + +// SetWorkspaceACLDisabled disables/enables workspace sharing for the +// deployment. +func SetWorkspaceACLDisabled(v bool) { + workspaceACLDisabled = v +} + +// WorkspaceACLDisabled returns true if workspace sharing is disabled +// for the deployment. +func WorkspaceACLDisabled() bool { + return workspaceACLDisabled +} diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 7d0a19ea64..3a9f70227a 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -5240,6 +5240,79 @@ func TestDeleteWorkspaceACL(t *testing.T) { }) } +// nolint:tparallel,paralleltest // Subtests modify package global. +func TestWorkspaceSharingDisabled(t *testing.T) { + t.Run("CanAccessWhenEnabled", func(t *testing.T) { + var ( + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t, func(dv *codersdk.DeploymentValues) { + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + // DisableWorkspaceSharing is false (default) + }), + }) + admin = coderdtest.CreateFirstUser(t, client) + _, wsOwner = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + userClient, user = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + ) + + ctx := testutil.Context(t, testutil.WaitMedium) + + // Create workspace with ACL granting access to user + ws := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: wsOwner.ID, + OrganizationID: admin.OrganizationID, + UserACL: database.WorkspaceACL{ + user.ID.String(): database.WorkspaceACLEntry{ + Permissions: []policy.Action{ + policy.ActionRead, policy.ActionSSH, policy.ActionApplicationConnect, + }, + }, + }, + }).Do().Workspace + + // User SHOULD be able to access workspace when sharing is enabled + fetchedWs, err := userClient.Workspace(ctx, ws.ID) + require.NoError(t, err) + require.Equal(t, ws.ID, fetchedWs.ID) + }) + + t.Run("NoAccessWhenDisabled", func(t *testing.T) { + var ( + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t, func(dv *codersdk.DeploymentValues) { + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + dv.DisableWorkspaceSharing = true + }), + }) + admin = coderdtest.CreateFirstUser(t, client) + _, wsOwner = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + userClient, user = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + ) + + ctx := testutil.Context(t, testutil.WaitMedium) + + // Create workspace with ACL granting access to user directly in DB + ws := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: wsOwner.ID, + OrganizationID: admin.OrganizationID, + UserACL: database.WorkspaceACL{ + user.ID.String(): database.WorkspaceACLEntry{ + Permissions: []policy.Action{ + policy.ActionRead, policy.ActionSSH, policy.ActionApplicationConnect, + }, + }, + }, + }).Do().Workspace + + // User should NOT be able to access workspace when sharing is disabled + _, err := userClient.Workspace(ctx, ws.ID) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) +} + func TestWorkspaceCreateWithImplicitPreset(t *testing.T) { t.Parallel() diff --git a/codersdk/deployment.go b/codersdk/deployment.go index d44c729271..0dd082ab5e 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -495,6 +495,7 @@ type DeploymentValues struct { SSHConfig SSHConfig `json:"config_ssh,omitempty" typescript:",notnull"` WgtunnelHost serpent.String `json:"wgtunnel_host,omitempty" typescript:",notnull"` DisableOwnerWorkspaceExec serpent.Bool `json:"disable_owner_workspace_exec,omitempty" typescript:",notnull"` + DisableWorkspaceSharing serpent.Bool `json:"disable_workspace_sharing,omitempty" typescript:",notnull"` ProxyHealthStatusInterval serpent.Duration `json:"proxy_health_status_interval,omitempty" typescript:",notnull"` EnableTerraformDebugMode serpent.Bool `json:"enable_terraform_debug_mode,omitempty" typescript:",notnull"` UserQuietHoursSchedule UserQuietHoursScheduleConfig `json:"user_quiet_hours_schedule,omitempty" typescript:",notnull"` @@ -2728,6 +2729,15 @@ func (c *DeploymentValues) Options() serpent.OptionSet { YAML: "disableOwnerWorkspaceAccess", Annotations: serpent.Annotations{}.Mark(annotationExternalProxies, "true"), }, + { + Name: "Disable Workspace Sharing", + Description: `Disable workspace sharing (requires the "workspace-sharing" experiment to be enabled). Workspace ACL checking is disabled and only owners can have ssh, apps and terminal access to workspaces. Access based on the 'owner' role is also allowed unless disabled via --disable-owner-workspace-access.`, + Flag: "disable-workspace-sharing", + Env: "CODER_DISABLE_WORKSPACE_SHARING", + + Value: &c.DisableWorkspaceSharing, + YAML: "disableWorkspaceSharing", + }, { Name: "Session Duration", Description: "The token expiry duration for browser sessions. Sessions may last longer if they are actively making requests, but this functionality can be disabled via --disable-session-expiry-refresh.", diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index b110c90041..3ea0180ae1 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -233,6 +233,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "disable_owner_workspace_exec": true, "disable_password_auth": true, "disable_path_apps": true, + "disable_workspace_sharing": true, "docs_url": { "forceQuery": true, "fragment": "string", diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 4e499fbae1..bd00d79c4b 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2917,6 +2917,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "disable_owner_workspace_exec": true, "disable_password_auth": true, "disable_path_apps": true, + "disable_workspace_sharing": true, "docs_url": { "forceQuery": true, "fragment": "string", @@ -3439,6 +3440,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "disable_owner_workspace_exec": true, "disable_password_auth": true, "disable_path_apps": true, + "disable_workspace_sharing": true, "docs_url": { "forceQuery": true, "fragment": "string", @@ -3793,6 +3795,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `disable_owner_workspace_exec` | boolean | false | | | | `disable_password_auth` | boolean | false | | | | `disable_path_apps` | boolean | false | | | +| `disable_workspace_sharing` | boolean | false | | | | `docs_url` | [serpent.URL](#serpenturl) | false | | | | `enable_authz_recording` | boolean | false | | | | `enable_terraform_debug_mode` | boolean | false | | | diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index 3f0a7550c0..4ba8c026fb 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -1115,6 +1115,16 @@ Disable workspace apps that are not served from subdomains. Path-based apps can Remove the permission for the 'owner' role to have workspace execution on all workspaces. This prevents the 'owner' from ssh, apps, and terminal access based on the 'owner' role. They still have their user permissions to access their own workspaces. +### --disable-workspace-sharing + +| | | +|-------------|-----------------------------------------------| +| Type | bool | +| Environment | $CODER_DISABLE_WORKSPACE_SHARING | +| YAML | disableWorkspaceSharing | + +Disable workspace sharing (requires the "workspace-sharing" experiment to be enabled). Workspace ACL checking is disabled and only owners can have ssh, apps and terminal access to workspaces. Access based on the 'owner' role is also allowed unless disabled via --disable-owner-workspace-access. + ### --session-duration | | | diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 94796825d6..32db725d93 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -47,6 +47,13 @@ OPTIONS: the workspace serves malicious JavaScript. This is recommended for security purposes if a --wildcard-access-url is configured. + --disable-workspace-sharing bool, $CODER_DISABLE_WORKSPACE_SHARING + Disable workspace sharing (requires the "workspace-sharing" experiment + to be enabled). Workspace ACL checking is disabled and only owners can + have ssh, apps and terminal access to workspaces. Access based on the + 'owner' role is also allowed unless disabled via + --disable-owner-workspace-access. + --swagger-enable bool, $CODER_SWAGGER_ENABLE Expose the swagger endpoint via /swagger. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b1d8c2bb44..6cb1474403 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1772,6 +1772,7 @@ export interface DeploymentValues { readonly config_ssh?: SSHConfig; readonly wgtunnel_host?: string; readonly disable_owner_workspace_exec?: boolean; + readonly disable_workspace_sharing?: boolean; readonly proxy_health_status_interval?: number; readonly enable_terraform_debug_mode?: boolean; readonly user_quiet_hours_schedule?: UserQuietHoursScheduleConfig;