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.
This commit is contained in:
George K
2025-12-09 08:13:09 -08:00
committed by GitHub
parent e31578da4b
commit 4379230a27
16 changed files with 202 additions and 2 deletions
+3
View File
@@ -14214,6 +14214,9 @@ const docTemplate = `{
"disable_path_apps": {
"type": "boolean"
},
"disable_workspace_sharing": {
"type": "boolean"
},
"docs_url": {
"$ref": "#/definitions/serpent.URL"
},
+3
View File
@@ -12798,6 +12798,9 @@
"disable_path_apps": {
"type": "boolean"
},
"disable_workspace_sharing": {
"type": "boolean"
},
"docs_url": {
"$ref": "#/definitions/serpent.URL"
},
+4
View File
@@ -333,6 +333,10 @@ func New(options *Options) *API {
})
}
if options.DeploymentValues.DisableWorkspaceSharing {
rbac.SetWorkspaceACLDisabled(true)
}
if options.PrometheusRegistry == nil {
options.PrometheusRegistry = prometheus.NewRegistry()
}
+10
View File
@@ -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
}
+9 -2
View File
@@ -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())
}
@@ -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()
+16
View File
@@ -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
}
+73
View File
@@ -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()