mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user