Skip to content

Commit 72f7d70

Browse files
authored
feat: allow TemplateAdmin to delete prebuilds via auth layer (#18333)
## Description This PR adds support for deleting prebuilt workspaces via the authorization layer. It introduces special-case handling to ensure that `prebuilt_workspace` permissions are evaluated when attempting to delete a prebuilt workspace, falling back to the standard `workspace` resource as needed. Prebuilt workspaces are a subset of workspaces, identified by having `owner_id` set to `PREBUILD_SYSTEM_USER`. This means: * A user with `prebuilt_workspace.delete` permission is allowed to **delete only prebuilt workspaces**. * A user with `workspace.delete` permission can **delete both normal and prebuilt workspaces**. ⚠️ This implementation is scoped to **deletion operations only**. No other operations are currently supported for the `prebuilt_workspace` resource. To delete a workspace, users must have the following permissions: * `workspace.read`: to read the current workspace state * `update`: to modify workspace metadata and related resources during deletion (e.g., updating the `deleted` field in the database) * `delete`: to perform the actual deletion of the workspace ## Changes * Introduced `authorizeWorkspace()` helper to handle prebuilt workspace authorization logic. * Ensured both `prebuilt_workspace` and `workspace` permissions are checked. * Added comments to clarify the current behavior and limitations. * Moved `SystemUserID` constant from the `prebuilds` package to the `database` package `PrebuildsSystemUserID` to resolve an import cycle (commit f24e4ab). * Update middleware `ExtractOrganizationMember` to include system user members.
1 parent d61353f commit 72f7d70

29 files changed

+493
-63
lines changed

cli/delete_test.go

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,18 @@ package cli_test
22

33
import (
44
"context"
5+
"database/sql"
56
"fmt"
67
"io"
78
"testing"
9+
"time"
10+
11+
"github.com/google/uuid"
12+
13+
"github.com/coder/coder/v2/coderd/database"
14+
"github.com/coder/coder/v2/coderd/database/dbgen"
15+
"github.com/coder/coder/v2/coderd/database/pubsub"
16+
"github.com/coder/quartz"
817

918
"github.com/stretchr/testify/assert"
1019
"github.com/stretchr/testify/require"
@@ -209,4 +218,225 @@ func TestDelete(t *testing.T) {
209218
cancel()
210219
<-doneChan
211220
})
221+
222+
t.Run("Prebuilt workspace delete permissions", func(t *testing.T) {
223+
t.Parallel()
224+
if !dbtestutil.WillUsePostgres() {
225+
t.Skip("this test requires postgres")
226+
}
227+
228+
clock := quartz.NewMock(t)
229+
ctx := testutil.Context(t, testutil.WaitSuperLong)
230+
231+
// Setup
232+
db, pb := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
233+
client, _ := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{
234+
Database: db,
235+
Pubsub: pb,
236+
IncludeProvisionerDaemon: true,
237+
})
238+
owner := coderdtest.CreateFirstUser(t, client)
239+
orgID := owner.OrganizationID
240+
241+
// Given a template version with a preset and a template
242+
version := coderdtest.CreateTemplateVersion(t, client, orgID, nil)
243+
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
244+
preset := setupTestDBPreset(t, db, version.ID)
245+
template := coderdtest.CreateTemplate(t, client, orgID, version.ID)
246+
247+
cases := []struct {
248+
name string
249+
client *codersdk.Client
250+
expectedPrebuiltDeleteErrMsg string
251+
expectedWorkspaceDeleteErrMsg string
252+
}{
253+
// Users with the OrgAdmin role should be able to delete both normal and prebuilt workspaces
254+
{
255+
name: "OrgAdmin",
256+
client: func() *codersdk.Client {
257+
client, _ := coderdtest.CreateAnotherUser(t, client, orgID, rbac.ScopedRoleOrgAdmin(orgID))
258+
return client
259+
}(),
260+
},
261+
// Users with the TemplateAdmin role should be able to delete prebuilt workspaces, but not normal workspaces
262+
{
263+
name: "TemplateAdmin",
264+
client: func() *codersdk.Client {
265+
client, _ := coderdtest.CreateAnotherUser(t, client, orgID, rbac.RoleTemplateAdmin())
266+
return client
267+
}(),
268+
expectedWorkspaceDeleteErrMsg: "unexpected status code 403: You do not have permission to delete this workspace.",
269+
},
270+
// Users with the OrgTemplateAdmin role should be able to delete prebuilt workspaces, but not normal workspaces
271+
{
272+
name: "OrgTemplateAdmin",
273+
client: func() *codersdk.Client {
274+
client, _ := coderdtest.CreateAnotherUser(t, client, orgID, rbac.ScopedRoleOrgTemplateAdmin(orgID))
275+
return client
276+
}(),
277+
expectedWorkspaceDeleteErrMsg: "unexpected status code 403: You do not have permission to delete this workspace.",
278+
},
279+
// Users with the Member role should not be able to delete prebuilt or normal workspaces
280+
{
281+
name: "Member",
282+
client: func() *codersdk.Client {
283+
client, _ := coderdtest.CreateAnotherUser(t, client, orgID, rbac.RoleMember())
284+
return client
285+
}(),
286+
expectedPrebuiltDeleteErrMsg: "unexpected status code 404: Resource not found or you do not have access to this resource",
287+
expectedWorkspaceDeleteErrMsg: "unexpected status code 404: Resource not found or you do not have access to this resource",
288+
},
289+
}
290+
291+
for _, tc := range cases {
292+
tc := tc
293+
t.Run(tc.name, func(t *testing.T) {
294+
t.Parallel()
295+
296+
// Create one prebuilt workspace (owned by system user) and one normal workspace (owned by a user)
297+
// Each workspace is persisted in the DB along with associated workspace jobs and builds.
298+
dbPrebuiltWorkspace := setupTestDBWorkspace(t, clock, db, pb, orgID, database.PrebuildsSystemUserID, template.ID, version.ID, preset.ID)
299+
userWorkspaceOwner, err := client.User(context.Background(), "testUser")
300+
require.NoError(t, err)
301+
dbUserWorkspace := setupTestDBWorkspace(t, clock, db, pb, orgID, userWorkspaceOwner.ID, template.ID, version.ID, preset.ID)
302+
303+
assertWorkspaceDelete := func(
304+
runClient *codersdk.Client,
305+
workspace database.Workspace,
306+
workspaceOwner string,
307+
expectedErr string,
308+
) {
309+
t.Helper()
310+
311+
// Attempt to delete the workspace as the test client
312+
inv, root := clitest.New(t, "delete", workspaceOwner+"/"+workspace.Name, "-y")
313+
clitest.SetupConfig(t, runClient, root)
314+
doneChan := make(chan struct{})
315+
pty := ptytest.New(t).Attach(inv)
316+
var runErr error
317+
go func() {
318+
defer close(doneChan)
319+
runErr = inv.Run()
320+
}()
321+
322+
// Validate the result based on the expected error message
323+
if expectedErr != "" {
324+
<-doneChan
325+
require.Error(t, runErr)
326+
require.Contains(t, runErr.Error(), expectedErr)
327+
} else {
328+
pty.ExpectMatch("has been deleted")
329+
<-doneChan
330+
331+
// When running with the race detector on, we sometimes get an EOF.
332+
if runErr != nil {
333+
assert.ErrorIs(t, runErr, io.EOF)
334+
}
335+
336+
// Verify that the workspace is now marked as deleted
337+
_, err := client.Workspace(context.Background(), workspace.ID)
338+
require.ErrorContains(t, err, "was deleted")
339+
}
340+
}
341+
342+
// Ensure at least one prebuilt workspace is reported as running in the database
343+
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
344+
running, err := db.GetRunningPrebuiltWorkspaces(ctx)
345+
if !assert.NoError(t, err) || !assert.GreaterOrEqual(t, len(running), 1) {
346+
return false
347+
}
348+
return true
349+
}, testutil.IntervalMedium, "running prebuilt workspaces timeout")
350+
351+
runningWorkspaces, err := db.GetRunningPrebuiltWorkspaces(ctx)
352+
require.NoError(t, err)
353+
require.GreaterOrEqual(t, len(runningWorkspaces), 1)
354+
355+
// Get the full prebuilt workspace object from the DB
356+
prebuiltWorkspace, err := db.GetWorkspaceByID(ctx, dbPrebuiltWorkspace.ID)
357+
require.NoError(t, err)
358+
359+
// Assert the prebuilt workspace deletion
360+
assertWorkspaceDelete(tc.client, prebuiltWorkspace, "prebuilds", tc.expectedPrebuiltDeleteErrMsg)
361+
362+
// Get the full user workspace object from the DB
363+
userWorkspace, err := db.GetWorkspaceByID(ctx, dbUserWorkspace.ID)
364+
require.NoError(t, err)
365+
366+
// Assert the user workspace deletion
367+
assertWorkspaceDelete(tc.client, userWorkspace, userWorkspaceOwner.Username, tc.expectedWorkspaceDeleteErrMsg)
368+
})
369+
}
370+
})
371+
}
372+
373+
func setupTestDBPreset(
374+
t *testing.T,
375+
db database.Store,
376+
templateVersionID uuid.UUID,
377+
) database.TemplateVersionPreset {
378+
t.Helper()
379+
380+
preset := dbgen.Preset(t, db, database.InsertPresetParams{
381+
TemplateVersionID: templateVersionID,
382+
Name: "preset-test",
383+
DesiredInstances: sql.NullInt32{
384+
Valid: true,
385+
Int32: 1,
386+
},
387+
})
388+
dbgen.PresetParameter(t, db, database.InsertPresetParametersParams{
389+
TemplateVersionPresetID: preset.ID,
390+
Names: []string{"test"},
391+
Values: []string{"test"},
392+
})
393+
394+
return preset
395+
}
396+
397+
func setupTestDBWorkspace(
398+
t *testing.T,
399+
clock quartz.Clock,
400+
db database.Store,
401+
ps pubsub.Pubsub,
402+
orgID uuid.UUID,
403+
ownerID uuid.UUID,
404+
templateID uuid.UUID,
405+
templateVersionID uuid.UUID,
406+
presetID uuid.UUID,
407+
) database.WorkspaceTable {
408+
t.Helper()
409+
410+
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
411+
TemplateID: templateID,
412+
OrganizationID: orgID,
413+
OwnerID: ownerID,
414+
Deleted: false,
415+
CreatedAt: time.Now().Add(-time.Hour * 2),
416+
})
417+
job := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{
418+
InitiatorID: ownerID,
419+
CreatedAt: time.Now().Add(-time.Hour * 2),
420+
StartedAt: sql.NullTime{Time: clock.Now().Add(-time.Hour * 2), Valid: true},
421+
CompletedAt: sql.NullTime{Time: clock.Now().Add(-time.Hour), Valid: true},
422+
OrganizationID: orgID,
423+
})
424+
workspaceBuild := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
425+
WorkspaceID: workspace.ID,
426+
InitiatorID: ownerID,
427+
TemplateVersionID: templateVersionID,
428+
JobID: job.ID,
429+
TemplateVersionPresetID: uuid.NullUUID{UUID: presetID, Valid: true},
430+
Transition: database.WorkspaceTransitionStart,
431+
CreatedAt: clock.Now(),
432+
})
433+
dbgen.WorkspaceBuildParameters(t, db, []database.WorkspaceBuildParameter{
434+
{
435+
WorkspaceBuildID: workspaceBuild.ID,
436+
Name: "test",
437+
Value: "test",
438+
},
439+
})
440+
441+
return workspace
212442
}

coderd/apidoc/docs.go

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/constants.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package database
2+
3+
import "github.com/google/uuid"
4+
5+
var PrebuildsSystemUserID = uuid.MustParse("c42fdf75-3097-471c-8c33-fb52454d81c0")

coderd/database/dbauthz/dbauthz.go

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import (
2121
"github.com/coder/coder/v2/coderd/database/dbtime"
2222
"github.com/coder/coder/v2/coderd/httpapi/httpapiconstraints"
2323
"github.com/coder/coder/v2/coderd/httpmw/loggermw"
24-
"github.com/coder/coder/v2/coderd/prebuilds"
2524
"github.com/coder/coder/v2/coderd/rbac"
2625
"github.com/coder/coder/v2/coderd/rbac/policy"
2726
"github.com/coder/coder/v2/coderd/rbac/rolestore"
@@ -150,6 +149,30 @@ func (q *querier) authorizeContext(ctx context.Context, action policy.Action, ob
150149
return nil
151150
}
152151

152+
// authorizePrebuiltWorkspace handles authorization for workspace resource types.
153+
// prebuilt_workspaces are a subset of workspaces, currently limited to
154+
// supporting delete operations. Therefore, if the action is delete or
155+
// update and the workspace is a prebuild, a prebuilt-specific authorization
156+
// is attempted first. If that fails, it falls back to normal workspace
157+
// authorization.
158+
// Note: Delete operations of workspaces requires both update and delete
159+
// permissions.
160+
func (q *querier) authorizePrebuiltWorkspace(ctx context.Context, action policy.Action, workspace database.Workspace) error {
161+
var prebuiltErr error
162+
// Special handling for prebuilt_workspace deletion authorization check
163+
if (action == policy.ActionUpdate || action == policy.ActionDelete) && workspace.IsPrebuild() {
164+
// Try prebuilt-specific authorization first
165+
if prebuiltErr = q.authorizeContext(ctx, action, workspace.AsPrebuild()); prebuiltErr == nil {
166+
return nil
167+
}
168+
}
169+
// Fallback to normal workspace authorization check
170+
if err := q.authorizeContext(ctx, action, workspace); err != nil {
171+
return xerrors.Errorf("authorize context: %w", errors.Join(prebuiltErr, err))
172+
}
173+
return nil
174+
}
175+
153176
type authContextKey struct{}
154177

155178
// ActorFromContext returns the authorization subject from the context.
@@ -399,7 +422,7 @@ var (
399422
subjectPrebuildsOrchestrator = rbac.Subject{
400423
Type: rbac.SubjectTypePrebuildsOrchestrator,
401424
FriendlyName: "Prebuilds Orchestrator",
402-
ID: prebuilds.SystemUserID.String(),
425+
ID: database.PrebuildsSystemUserID.String(),
403426
Roles: rbac.Roles([]rbac.Role{
404427
{
405428
Identifier: rbac.RoleIdentifier{Name: "prebuilds-orchestrator"},
@@ -412,6 +435,12 @@ var (
412435
policy.ActionCreate, policy.ActionDelete, policy.ActionRead, policy.ActionUpdate,
413436
policy.ActionWorkspaceStart, policy.ActionWorkspaceStop,
414437
},
438+
// PrebuiltWorkspaces are a subset of Workspaces.
439+
// Explicitly setting PrebuiltWorkspace permissions for clarity.
440+
// Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions.
441+
rbac.ResourcePrebuiltWorkspace.Type: {
442+
policy.ActionUpdate, policy.ActionDelete,
443+
},
415444
// Should be able to add the prebuilds system user as a member to any organization that needs prebuilds.
416445
rbac.ResourceOrganizationMember.Type: {
417446
policy.ActionCreate,
@@ -3953,8 +3982,9 @@ func (q *querier) InsertWorkspaceBuild(ctx context.Context, arg database.InsertW
39533982
action = policy.ActionWorkspaceStop
39543983
}
39553984

3956-
if err = q.authorizeContext(ctx, action, w); err != nil {
3957-
return xerrors.Errorf("authorize context: %w", err)
3985+
// Special handling for prebuilt workspace deletion
3986+
if err := q.authorizePrebuiltWorkspace(ctx, action, w); err != nil {
3987+
return err
39583988
}
39593989

39603990
// If we're starting a workspace we need to check the template.
@@ -3993,8 +4023,8 @@ func (q *querier) InsertWorkspaceBuildParameters(ctx context.Context, arg databa
39934023
return err
39944024
}
39954025

3996-
err = q.authorizeContext(ctx, policy.ActionUpdate, workspace)
3997-
if err != nil {
4026+
// Special handling for prebuilt workspace deletion
4027+
if err := q.authorizePrebuiltWorkspace(ctx, policy.ActionUpdate, workspace); err != nil {
39984028
return err
39994029
}
40004030

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy