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
f24e4ab4b6).
* 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

View File

@ -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"
@ -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))
}
return nil
}
type authContextKey struct{}
// ActorFromContext returns the authorization subject from the context.
@ -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"},
@ -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,
@ -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.
@ -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
}

View File

@ -5562,3 +5562,63 @@ func (s *MethodTestSuite) TestChat() {
}).Asserts(c, policy.ActionUpdate)
}))
}
func (s *MethodTestSuite) TestAuthorizePrebuiltWorkspace() {
s.Run("PrebuildDelete/InsertWorkspaceBuild", s.Subtest(func(db database.Store, check *expects) {
u := dbgen.User(s.T(), db, database.User{})
o := dbgen.Organization(s.T(), db, database.Organization{})
tpl := dbgen.Template(s.T(), db, database.Template{
OrganizationID: o.ID,
CreatedBy: u.ID,
})
w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{
TemplateID: tpl.ID,
OrganizationID: o.ID,
OwnerID: database.PrebuildsSystemUserID,
})
pj := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{
OrganizationID: o.ID,
})
tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{
TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true},
OrganizationID: o.ID,
CreatedBy: u.ID,
})
check.Args(database.InsertWorkspaceBuildParams{
WorkspaceID: w.ID,
Transition: database.WorkspaceTransitionDelete,
Reason: database.BuildReasonInitiator,
TemplateVersionID: tv.ID,
JobID: pj.ID,
}).Asserts(w.AsPrebuild(), policy.ActionDelete)
}))
s.Run("PrebuildUpdate/InsertWorkspaceBuildParameters", s.Subtest(func(db database.Store, check *expects) {
u := dbgen.User(s.T(), db, database.User{})
o := dbgen.Organization(s.T(), db, database.Organization{})
tpl := dbgen.Template(s.T(), db, database.Template{
OrganizationID: o.ID,
CreatedBy: u.ID,
})
w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{
TemplateID: tpl.ID,
OrganizationID: o.ID,
OwnerID: database.PrebuildsSystemUserID,
})
pj := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{
OrganizationID: o.ID,
})
tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{
TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true},
OrganizationID: o.ID,
CreatedBy: u.ID,
})
wb := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{
JobID: pj.ID,
WorkspaceID: w.ID,
TemplateVersionID: tv.ID,
})
check.Args(database.InsertWorkspaceBuildParametersParams{
WorkspaceBuildID: wb.ID,
}).Asserts(w.AsPrebuild(), policy.ActionUpdate)
}))
}