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
https://github.com/coder/coder/pull/18333/commits/f24e4ab4b6f0a56726fd04be2d7302c9fdb52d53).
* Update middleware `ExtractOrganizationMember` to include system user
members.
This commit is contained in:
Susana Ferreira
2025-06-20 17:36:32 +01:00
committed by GitHub
parent d61353f468
commit 72f7d70bab
29 changed files with 493 additions and 63 deletions
+230
View File
@@ -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"
@@ -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
}