From f65b915fe393e3a4d2c273f8fe569a5915b9ba65 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 25 Mar 2026 13:46:58 -0500 Subject: [PATCH] chore: add permissions to `coder:workspace.*` scopes for functionality (#23515) `coder:workspaces.*` composite scopes did not provide enough permissions to do what they say they can do. Closes https://github.com/coder/coder/issues/22537 --- coderd/rbac/scopes.go | 17 ++- coderd/workspaces_scoped_test.go | 171 +++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 coderd/workspaces_scoped_test.go diff --git a/coderd/rbac/scopes.go b/coderd/rbac/scopes.go index 8de6add63e..17e3990c31 100644 --- a/coderd/rbac/scopes.go +++ b/coderd/rbac/scopes.go @@ -135,16 +135,25 @@ func BuiltinScopeNames() []ScopeName { var compositePerms = map[ScopeName]map[string][]policy.Action{ "coder:workspaces.create": { ResourceTemplate.Type: {policy.ActionRead, policy.ActionUse}, - ResourceWorkspace.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionRead}, + ResourceWorkspace.Type: {policy.ActionWorkspaceStop, policy.ActionWorkspaceStart, policy.ActionCreate, policy.ActionUpdate, policy.ActionRead}, + // When creating a workspace, users need to be able to read the org member the + // workspace will be owned by. Even if that owner is "yourself". + ResourceOrganizationMember.Type: {policy.ActionRead}, }, "coder:workspaces.operate": { - ResourceWorkspace.Type: {policy.ActionRead, policy.ActionUpdate}, + ResourceTemplate.Type: {policy.ActionRead}, + ResourceWorkspace.Type: {policy.ActionWorkspaceStop, policy.ActionWorkspaceStart, policy.ActionRead, policy.ActionUpdate}, + ResourceOrganizationMember.Type: {policy.ActionRead}, }, "coder:workspaces.delete": { - ResourceWorkspace.Type: {policy.ActionRead, policy.ActionDelete}, + ResourceTemplate.Type: {policy.ActionRead, policy.ActionUse}, + ResourceWorkspace.Type: {policy.ActionRead, policy.ActionDelete}, + ResourceOrganizationMember.Type: {policy.ActionRead}, }, "coder:workspaces.access": { - ResourceWorkspace.Type: {policy.ActionRead, policy.ActionSSH, policy.ActionApplicationConnect}, + ResourceTemplate.Type: {policy.ActionRead}, + ResourceOrganizationMember.Type: {policy.ActionRead}, + ResourceWorkspace.Type: {policy.ActionRead, policy.ActionSSH, policy.ActionApplicationConnect}, }, "coder:templates.build": { ResourceTemplate.Type: {policy.ActionRead}, diff --git a/coderd/workspaces_scoped_test.go b/coderd/workspaces_scoped_test.go new file mode 100644 index 0000000000..016487306d --- /dev/null +++ b/coderd/workspaces_scoped_test.go @@ -0,0 +1,171 @@ +package coderd_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/testutil" +) + +// TestCompositeWorkspaceScopes verifies that the composite +// coder:workspaces.* scopes grant the permissions needed for +// workspace lifecycle operations when used on scoped API tokens. +func TestCompositeWorkspaceScopes(t *testing.T) { + t.Parallel() + + // setupWorkspace creates a server with a provisioner daemon, an + // admin user, a template, and a workspace. It returns the admin + // client and the workspace so sub-tests can create scoped tokens + // and act on them. + type setupResult struct { + adminClient *codersdk.Client + workspace codersdk.Workspace + } + setup := func(t *testing.T) setupResult { + t.Helper() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + firstUser := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, firstUser.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionGraph: echo.GraphComplete, + }) + template := coderdtest.CreateTemplate(t, client, firstUser.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + return setupResult{ + adminClient: client, + workspace: workspace, + } + } + + // scopedClient creates an API token restricted to the given scopes + // and returns a new client authenticated with that token. + scopedClient := func(t *testing.T, adminClient *codersdk.Client, scopes []codersdk.APIKeyScope) *codersdk.Client { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitShort) + defer cancel() + + resp, err := adminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Scopes: scopes, + }) + require.NoError(t, err, "creating scoped token") + + scoped := codersdk.New(adminClient.URL, codersdk.WithSessionToken(resp.Key)) + t.Cleanup(func() { scoped.HTTPClient.CloseIdleConnections() }) + return scoped + } + + // coder:workspaces.create — token should be able to create a + // workspace via POST /users/{user}/workspaces. + t.Run("WorkspacesCreate", func(t *testing.T) { + t.Parallel() + s := setup(t) + + scoped := scopedClient(t, s.adminClient, []codersdk.APIKeyScope{ + codersdk.APIKeyScopeCoderWorkspacesCreate, + }) + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + defer cancel() + + // List workspaces (requires workspace:read, included in the + // composite scope). + workspaces, err := scoped.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err, "listing workspaces with coder:workspaces.create scope") + require.NotEmpty(t, workspaces.Workspaces, "should see at least the existing workspace") + + _, err = scoped.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: s.workspace.TemplateID, + Name: coderdtest.RandomUsername(t), + }) + require.NoError(t, err, "creating workspace with coder:workspaces.create scope") + }) + + // coder:workspaces.operate — token should be able to read and + // update workspace metadata. + t.Run("WorkspacesOperate", func(t *testing.T) { + t.Parallel() + s := setup(t) + + scoped := scopedClient(t, s.adminClient, []codersdk.APIKeyScope{ + codersdk.APIKeyScopeCoderWorkspacesOperate, + }) + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + defer cancel() + + // Read the workspace by ID (requires workspace:read). + ws, err := scoped.Workspace(ctx, s.workspace.ID) + require.NoError(t, err, "reading workspace with coder:workspaces.operate scope") + require.Equal(t, s.workspace.ID, ws.ID) + + // Update the workspace metadata (requires workspace:update). This goes + // through the PATCH /workspaces/{workspace} endpoint. + err = scoped.UpdateWorkspaceTTL(ctx, s.workspace.ID, codersdk.UpdateWorkspaceTTLRequest{ + TTLMillis: ptr.Ref[int64]((time.Hour).Milliseconds()), + }) + require.NoError(t, err, "updating workspace with coder:workspaces.operate scope") + + // Trigger a start build (requires workspace:update). This goes + // through POST /workspaces/{workspace}/builds. + started, err := scoped.CreateWorkspaceBuild(ctx, s.workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: ws.LatestBuild.TemplateVersionID, + Transition: codersdk.WorkspaceTransitionStart, + }) + require.NoError(t, err, "starting workspace with coder:workspaces.operate scope") + coderdtest.AwaitWorkspaceBuildJobCompleted(t, scoped, started.ID) + + _, err = scoped.CreateWorkspaceBuild(ctx, s.workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: ws.LatestBuild.TemplateVersionID, + Transition: codersdk.WorkspaceTransitionStop, + }) + require.NoError(t, err, "starting workspace with coder:workspaces.operate scope") + + // Verify we cannot create a new workspace — the operate scope + // should not include workspace:create or template:read/use. + _, err = scoped.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: s.workspace.TemplateID, + Name: coderdtest.RandomUsername(t), + }) + require.Error(t, err, "creating workspace should fail with coder:workspaces.operate scope") + }) + + // coder:workspaces.delete — token should be able to read + // workspaces and trigger a delete build. + t.Run("WorkspacesDelete", func(t *testing.T) { + t.Parallel() + s := setup(t) + + scoped := scopedClient(t, s.adminClient, []codersdk.APIKeyScope{ + codersdk.APIKeyScopeCoderWorkspacesDelete, + }) + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + defer cancel() + + // Read the workspace by ID (requires workspace:read). + ws, err := scoped.Workspace(ctx, s.workspace.ID) + require.NoError(t, err, "reading workspace with coder:workspaces.delete scope") + require.Equal(t, s.workspace.ID, ws.ID) + + // Delete the workspace via a delete transition build. + _, err = scoped.CreateWorkspaceBuild(ctx, s.workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: ws.LatestBuild.TemplateVersionID, + Transition: codersdk.WorkspaceTransitionDelete, + }) + require.NoError(t, err, "deleting workspace with coder:workspaces.delete scope") + }) +}