Skip to content

feat: allow TemplateAdmin to delete prebuilds via auth layer #18333

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Jun 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
230 changes: 230 additions & 0 deletions cli/delete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,18 @@ package cli_test

import (
"context"
"database/sql"
"fmt"
"io"
"testing"
"time"

"github.com/google/uuid"

"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/quartz"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -209,4 +218,225 @@ func TestDelete(t *testing.T) {
cancel()
<-doneChan
})

t.Run("Prebuilt workspace delete permissions", func(t *testing.T) {
t.Parallel()
if !dbtestutil.WillUsePostgres() {
t.Skip("this test requires postgres")
}

clock := quartz.NewMock(t)
ctx := testutil.Context(t, testutil.WaitSuperLong)

// Setup
db, pb := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
client, _ := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{
Database: db,
Pubsub: pb,
IncludeProvisionerDaemon: true,
})
owner := coderdtest.CreateFirstUser(t, client)
orgID := owner.OrganizationID

// Given a template version with a preset and a template
version := coderdtest.CreateTemplateVersion(t, client, orgID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
preset := setupTestDBPreset(t, db, version.ID)
template := coderdtest.CreateTemplate(t, client, orgID, version.ID)

cases := []struct {
name string
client *codersdk.Client
expectedPrebuiltDeleteErrMsg string
expectedWorkspaceDeleteErrMsg string
}{
// Users with the OrgAdmin role should be able to delete both normal and prebuilt workspaces
{
name: "OrgAdmin",
client: func() *codersdk.Client {
client, _ := coderdtest.CreateAnotherUser(t, client, orgID, rbac.ScopedRoleOrgAdmin(orgID))
return client
}(),
},
// Users with the TemplateAdmin role should be able to delete prebuilt workspaces, but not normal workspaces
{
name: "TemplateAdmin",
client: func() *codersdk.Client {
client, _ := coderdtest.CreateAnotherUser(t, client, orgID, rbac.RoleTemplateAdmin())
return client
}(),
expectedWorkspaceDeleteErrMsg: "unexpected status code 403: You do not have permission to delete this workspace.",
},
// Users with the OrgTemplateAdmin role should be able to delete prebuilt workspaces, but not normal workspaces
{
name: "OrgTemplateAdmin",
client: func() *codersdk.Client {
client, _ := coderdtest.CreateAnotherUser(t, client, orgID, rbac.ScopedRoleOrgTemplateAdmin(orgID))
return client
}(),
expectedWorkspaceDeleteErrMsg: "unexpected status code 403: You do not have permission to delete this workspace.",
},
// Users with the Member role should not be able to delete prebuilt or normal workspaces
{
name: "Member",
client: func() *codersdk.Client {
client, _ := coderdtest.CreateAnotherUser(t, client, orgID, rbac.RoleMember())
return client
}(),
expectedPrebuiltDeleteErrMsg: "unexpected status code 404: Resource not found or you do not have access to this resource",
expectedWorkspaceDeleteErrMsg: "unexpected status code 404: Resource not found or you do not have access to this resource",
},
}

for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

// Create one prebuilt workspace (owned by system user) and one normal workspace (owned by a user)
// Each workspace is persisted in the DB along with associated workspace jobs and builds.
dbPrebuiltWorkspace := setupTestDBWorkspace(t, clock, db, pb, orgID, database.PrebuildsSystemUserID, template.ID, version.ID, preset.ID)
userWorkspaceOwner, err := client.User(context.Background(), "testUser")
require.NoError(t, err)
dbUserWorkspace := setupTestDBWorkspace(t, clock, db, pb, orgID, userWorkspaceOwner.ID, template.ID, version.ID, preset.ID)

assertWorkspaceDelete := func(
runClient *codersdk.Client,
workspace database.Workspace,
workspaceOwner string,
expectedErr string,
) {
t.Helper()

// Attempt to delete the workspace as the test client
inv, root := clitest.New(t, "delete", workspaceOwner+"/"+workspace.Name, "-y")
clitest.SetupConfig(t, runClient, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
var runErr error
go func() {
defer close(doneChan)
runErr = inv.Run()
}()

// Validate the result based on the expected error message
if expectedErr != "" {
<-doneChan
require.Error(t, runErr)
require.Contains(t, runErr.Error(), expectedErr)
} else {
pty.ExpectMatch("has been deleted")
<-doneChan

// When running with the race detector on, we sometimes get an EOF.
if runErr != nil {
assert.ErrorIs(t, runErr, io.EOF)
}

// Verify that the workspace is now marked as deleted
_, err := client.Workspace(context.Background(), workspace.ID)
require.ErrorContains(t, err, "was deleted")
}
}

// Ensure at least one prebuilt workspace is reported as running in the database
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
running, err := db.GetRunningPrebuiltWorkspaces(ctx)
if !assert.NoError(t, err) || !assert.GreaterOrEqual(t, len(running), 1) {
return false
}
return true
}, testutil.IntervalMedium, "running prebuilt workspaces timeout")

runningWorkspaces, err := db.GetRunningPrebuiltWorkspaces(ctx)
require.NoError(t, err)
require.GreaterOrEqual(t, len(runningWorkspaces), 1)

// Get the full prebuilt workspace object from the DB
prebuiltWorkspace, err := db.GetWorkspaceByID(ctx, dbPrebuiltWorkspace.ID)
require.NoError(t, err)

// Assert the prebuilt workspace deletion
assertWorkspaceDelete(tc.client, prebuiltWorkspace, "prebuilds", tc.expectedPrebuiltDeleteErrMsg)

// Get the full user workspace object from the DB
userWorkspace, err := db.GetWorkspaceByID(ctx, dbUserWorkspace.ID)
require.NoError(t, err)

// Assert the user workspace deletion
assertWorkspaceDelete(tc.client, userWorkspace, userWorkspaceOwner.Username, tc.expectedWorkspaceDeleteErrMsg)
})
}
})
}

func setupTestDBPreset(
t *testing.T,
db database.Store,
templateVersionID uuid.UUID,
) database.TemplateVersionPreset {
t.Helper()

preset := dbgen.Preset(t, db, database.InsertPresetParams{
TemplateVersionID: templateVersionID,
Name: "preset-test",
DesiredInstances: sql.NullInt32{
Valid: true,
Int32: 1,
},
})
dbgen.PresetParameter(t, db, database.InsertPresetParametersParams{
TemplateVersionPresetID: preset.ID,
Names: []string{"test"},
Values: []string{"test"},
})

return preset
}

func setupTestDBWorkspace(
t *testing.T,
clock quartz.Clock,
db database.Store,
ps pubsub.Pubsub,
orgID uuid.UUID,
ownerID uuid.UUID,
templateID uuid.UUID,
templateVersionID uuid.UUID,
presetID uuid.UUID,
) database.WorkspaceTable {
t.Helper()

workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
TemplateID: templateID,
OrganizationID: orgID,
OwnerID: ownerID,
Deleted: false,
CreatedAt: time.Now().Add(-time.Hour * 2),
})
job := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{
InitiatorID: ownerID,
CreatedAt: time.Now().Add(-time.Hour * 2),
StartedAt: sql.NullTime{Time: clock.Now().Add(-time.Hour * 2), Valid: true},
CompletedAt: sql.NullTime{Time: clock.Now().Add(-time.Hour), Valid: true},
OrganizationID: orgID,
})
workspaceBuild := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
WorkspaceID: workspace.ID,
InitiatorID: ownerID,
TemplateVersionID: templateVersionID,
JobID: job.ID,
TemplateVersionPresetID: uuid.NullUUID{UUID: presetID, Valid: true},
Transition: database.WorkspaceTransitionStart,
CreatedAt: clock.Now(),
})
dbgen.WorkspaceBuildParameters(t, db, []database.WorkspaceBuildParameter{
{
WorkspaceBuildID: workspaceBuild.ID,
Name: "test",
Value: "test",
},
})

return workspace
}
2 changes: 2 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions coderd/database/constants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package database

import "github.com/google/uuid"

var PrebuildsSystemUserID = uuid.MustParse("c42fdf75-3097-471c-8c33-fb52454d81c0")
42 changes: 36 additions & 6 deletions coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpapi/httpapiconstraints"
"github.com/coder/coder/v2/coderd/httpmw/loggermw"
"github.com/coder/coder/v2/coderd/prebuilds"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/rbac/rolestore"
Expand Down Expand Up @@ -150,6 +149,30 @@ func (q *querier) authorizeContext(ctx context.Context, action policy.Action, ob
return nil
}

// authorizePrebuiltWorkspace handles authorization for workspace resource types.
// prebuilt_workspaces are a subset of workspaces, currently limited to
// supporting delete operations. Therefore, if the action is delete or
// update and the workspace is a prebuild, a prebuilt-specific authorization
// is attempted first. If that fails, it falls back to normal workspace
// authorization.
// Note: Delete operations of workspaces requires both update and delete
// permissions.
func (q *querier) authorizePrebuiltWorkspace(ctx context.Context, action policy.Action, workspace database.Workspace) error {
var prebuiltErr error
// Special handling for prebuilt_workspace deletion authorization check
if (action == policy.ActionUpdate || action == policy.ActionDelete) && workspace.IsPrebuild() {
// Try prebuilt-specific authorization first
if prebuiltErr = q.authorizeContext(ctx, action, workspace.AsPrebuild()); prebuiltErr == nil {
return nil
}
}
// Fallback to normal workspace authorization check
if err := q.authorizeContext(ctx, action, workspace); err != nil {
return xerrors.Errorf("authorize context: %w", errors.Join(prebuiltErr, err))
}
Comment on lines +163 to +172
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the normal workspace check is more likely to succeed then the prebuild. So it might be a slight optimization to flip these checks.

But that will probably break some of the unit tests? No need to change this, just a thought.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I had considered that optimization as well. I believe it might require some test updates, so I'd prefer to merge this PR and address the optimization in a follow-up PR.

return nil
}

type authContextKey struct{}

// ActorFromContext returns the authorization subject from the context.
Expand Down Expand Up @@ -399,7 +422,7 @@ var (
subjectPrebuildsOrchestrator = rbac.Subject{
Type: rbac.SubjectTypePrebuildsOrchestrator,
FriendlyName: "Prebuilds Orchestrator",
ID: prebuilds.SystemUserID.String(),
ID: database.PrebuildsSystemUserID.String(),
Roles: rbac.Roles([]rbac.Role{
{
Identifier: rbac.RoleIdentifier{Name: "prebuilds-orchestrator"},
Expand All @@ -412,6 +435,12 @@ var (
policy.ActionCreate, policy.ActionDelete, policy.ActionRead, policy.ActionUpdate,
policy.ActionWorkspaceStart, policy.ActionWorkspaceStop,
},
// PrebuiltWorkspaces are a subset of Workspaces.
// Explicitly setting PrebuiltWorkspace permissions for clarity.
// Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions.
rbac.ResourcePrebuiltWorkspace.Type: {
policy.ActionUpdate, policy.ActionDelete,
},
// Should be able to add the prebuilds system user as a member to any organization that needs prebuilds.
rbac.ResourceOrganizationMember.Type: {
policy.ActionCreate,
Expand Down Expand Up @@ -3953,8 +3982,9 @@ func (q *querier) InsertWorkspaceBuild(ctx context.Context, arg database.InsertW
action = policy.ActionWorkspaceStop
}

if err = q.authorizeContext(ctx, action, w); err != nil {
return xerrors.Errorf("authorize context: %w", err)
// Special handling for prebuilt workspace deletion
if err := q.authorizePrebuiltWorkspace(ctx, action, w); err != nil {
return err
}

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

err = q.authorizeContext(ctx, policy.ActionUpdate, workspace)
if err != nil {
// Special handling for prebuilt workspace deletion
if err := q.authorizePrebuiltWorkspace(ctx, policy.ActionUpdate, workspace); err != nil {
return err
}

Expand Down
Loading
Loading