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

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
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
219 changes: 219 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,214 @@ func TestDelete(t *testing.T) {
cancel()
<-doneChan
})

t.Run("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 the owner 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)
dbUserWorkspace := setupTestDBWorkspace(t, clock, db, pb, orgID, owner.UserID, template.ID, version.ID, preset.ID)

// 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, runningWorkspaces[0].ID)
require.NoError(t, err)

// Attempt to delete the prebuilt workspace as the test client
build, err := tc.client.CreateWorkspaceBuild(ctx, dbPrebuiltWorkspace.ID, codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionDelete,
})
// Validate the result based on the expected error message
if tc.expectedPrebuiltDeleteErrMsg != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.expectedPrebuiltDeleteErrMsg)
} else {
require.NoError(t, err, "delete the prebuilt workspace")
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID)

// Verify that the prebuilt workspace is now marked as deleted
deletedWorkspace, err := client.DeletedWorkspace(ctx, prebuiltWorkspace.ID)
require.NoError(t, err)
require.Equal(t, prebuiltWorkspace.ID, deletedWorkspace.ID)
}

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

// Attempt to delete the prebuilt workspace as the test client
build, err = tc.client.CreateWorkspaceBuild(ctx, dbUserWorkspace.ID, codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionDelete,
})
// Validate the result based on the expected error message
if tc.expectedWorkspaceDeleteErrMsg != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.expectedWorkspaceDeleteErrMsg)
} else {
require.NoError(t, err, "delete the user Workspace")
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID)

// Verify that the user workspace is now marked as deleted
deletedWorkspace, err := client.DeletedWorkspace(ctx, userWorkspace.ID)
require.NoError(t, err)
require.Equal(t, userWorkspace.ID, deletedWorkspace.ID)
}
})
}
})
}

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))
}
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 @@ -3932,8 +3961,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 @@ -3972,8 +4002,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
6 changes: 2 additions & 4 deletions coderd/database/dbmem/dbmem.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,9 @@ import (
"golang.org/x/exp/maps"
"golang.org/x/xerrors"

"github.com/coder/coder/v2/coderd/notifications/types"
"github.com/coder/coder/v2/coderd/prebuilds"

"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/notifications/types"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/regosql"
"github.com/coder/coder/v2/coderd/util/slice"
Expand Down Expand Up @@ -159,7 +157,7 @@ func New() database.Store {
q.mutex.Lock()
// We can't insert this user using the interface, because it's a system user.
q.data.users = append(q.data.users, database.User{
ID: prebuilds.SystemUserID,
ID: database.PrebuildsSystemUserID,
Email: "prebuilds@coder.com",
Username: "prebuilds",
CreatedAt: dbtime.Now(),
Expand Down
Loading
Loading