Skip to content

refactor: consolidate template and workspace acl validation #19192

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 16 commits into from
Aug 7, 2025
3 changes: 2 additions & 1 deletion coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -1415,7 +1415,8 @@ func New(options *Options) *API {
r.Get("/timings", api.workspaceTimings)
r.Route("/acl", func(r chi.Router) {
r.Use(
httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentWorkspaceSharing))
httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentWorkspaceSharing),
)

r.Patch("/", api.patchWorkspaceACL)
})
Expand Down
20 changes: 20 additions & 0 deletions coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -5376,6 +5376,26 @@ func (q *querier) UpsertWorkspaceAppAuditSession(ctx context.Context, arg databa
return q.db.UpsertWorkspaceAppAuditSession(ctx, arg)
}

func (q *querier) ValidateGroupIDs(ctx context.Context, groupIDs []uuid.UUID) (database.ValidateGroupIDsRow, error) {
// This check is probably overly restrictive, but the "correct" check isn't
// necessarily obvious. It's only used as a verification check for ACLs right
// now, which are performed as system.
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
return database.ValidateGroupIDsRow{}, err
}
return q.db.ValidateGroupIDs(ctx, groupIDs)
}

func (q *querier) ValidateUserIDs(ctx context.Context, userIDs []uuid.UUID) (database.ValidateUserIDsRow, error) {
// This check is probably overly restrictive, but the "correct" check isn't
// necessarily obvious. It's only used as a verification check for ACLs right
// now, which are performed as system.
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
return database.ValidateUserIDsRow{}, err
}
return q.db.ValidateUserIDs(ctx, userIDs)
}

func (q *querier) GetAuthorizedTemplates(ctx context.Context, arg database.GetTemplatesWithFilterParams, _ rbac.PreparedAuthorized) ([]database.Template, error) {
// TODO Delete this function, all GetTemplates should be authorized. For now just call getTemplates on the authz querier.
return q.GetTemplatesWithFilter(ctx, arg)
Expand Down
9 changes: 9 additions & 0 deletions coderd/database/dbauthz/dbauthz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,11 @@ func (s *MethodTestSuite) TestGroup() {
ID: g.ID,
}).Asserts(g, policy.ActionUpdate)
}))
s.Run("ValidateGroupIDs", s.Subtest(func(db database.Store, check *expects) {
o := dbgen.Organization(s.T(), db, database.Organization{})
g := dbgen.Group(s.T(), db, database.Group{OrganizationID: o.ID})
check.Args([]uuid.UUID{g.ID}).Asserts(rbac.ResourceSystem, policy.ActionRead)
}))
}

func (s *MethodTestSuite) TestProvisionerJob() {
Expand Down Expand Up @@ -2077,6 +2082,10 @@ func (s *MethodTestSuite) TestUser() {
Interval: int32((time.Hour * 24).Seconds()),
}).Asserts(rbac.ResourceUser, policy.ActionRead)
}))
s.Run("ValidateUserIDs", s.Subtest(func(db database.Store, check *expects) {
u := dbgen.User(s.T(), db, database.User{})
check.Args([]uuid.UUID{u.ID}).Asserts(rbac.ResourceSystem, policy.ActionRead)
}))
}

func (s *MethodTestSuite) TestWorkspace() {
Expand Down
14 changes: 14 additions & 0 deletions coderd/database/dbmetrics/querymetrics.go

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

30 changes: 30 additions & 0 deletions coderd/database/dbmock/dbmock.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/database/querier.go

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

64 changes: 64 additions & 0 deletions coderd/database/queries.sql.go

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

18 changes: 18 additions & 0 deletions coderd/database/queries/groups.sql
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@ WHERE
LIMIT
1;

-- name: ValidateGroupIDs :one
WITH input AS (
SELECT
unnest(@group_ids::uuid[]) AS id
)
SELECT
array_agg(input.id)::uuid[] as invalid_group_ids,
COUNT(*) = 0 as ok
FROM
-- Preserve rows where there is not a matching left (groups) row for each
-- right (input) row...
groups
RIGHT JOIN input ON groups.id = input.id
WHERE
-- ...so that we can retain exactly those rows where an input ID does not
-- match an existing group.
groups.id IS NULL;

-- name: GetGroupByOrgAndName :one
SELECT
*
Expand Down
20 changes: 20 additions & 0 deletions coderd/database/queries/users.sql
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,26 @@ WHERE
LIMIT
1;

-- name: ValidateUserIDs :one
WITH input AS (
SELECT
unnest(@user_ids::uuid[]) AS id
)
SELECT
array_agg(input.id)::uuid[] as invalid_user_ids,
COUNT(*) = 0 as ok
FROM
-- Preserve rows where there is not a matching left (users) row for each
-- right (input) row...
users
RIGHT JOIN input ON users.id = input.id
WHERE
-- ...so that we can retain exactly those rows where an input ID does not
-- match an existing user...
users.id IS NULL OR
-- ...or that only matches a user that was deleted.
users.deleted = true;

-- name: GetUsersByIDs :many
-- This shouldn't check for deleted, because it's frequently used
-- to look up references to actions. eg. a user could build a workspace
Expand Down
130 changes: 130 additions & 0 deletions coderd/rbac/acl/updatevalidator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package acl

import (
"context"
"fmt"

"github.com/google/uuid"

"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/codersdk"
)

type UpdateValidator[Role codersdk.WorkspaceRole | codersdk.TemplateRole] interface {
// Users should return a map from user UUIDs (as strings) to the role they
// are being assigned. Additionally, it should return a string that will be
// used as the field name for the ValidationErrors returned from Validate.
Users() (map[string]Role, string)
// Groups should return a map from group UUIDs (as strings) to the role they
// are being assigned. Additionally, it should return a string that will be
// used as the field name for the ValidationErrors returned from Validate.
Groups() (map[string]Role, string)
// ValidateRole should return an error that will be used in the
// ValidationError if the role is invalid for the corresponding resource type.
ValidateRole(role Role) error
}
Comment on lines +14 to +26
Copy link
Member

Choose a reason for hiding this comment

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

❤️


func Validate[Role codersdk.WorkspaceRole | codersdk.TemplateRole](
ctx context.Context,
db database.Store,
v UpdateValidator[Role],
) []codersdk.ValidationError {
// nolint:gocritic // Validate requires full read access to users and groups
ctx = dbauthz.AsSystemRestricted(ctx)
var validErrs []codersdk.ValidationError

groupRoles, groupsField := v.Groups()
groupIDs := make([]uuid.UUID, 0, len(groupRoles))
for idStr, role := range groupRoles {
// Validate the provided role names
if err := v.ValidateRole(role); err != nil {
validErrs = append(validErrs, codersdk.ValidationError{
Field: groupsField,
Detail: err.Error(),
})
}
// Validate that the IDs are UUIDs
id, err := uuid.Parse(idStr)
if err != nil {
validErrs = append(validErrs, codersdk.ValidationError{
Field: groupsField,
Detail: fmt.Sprintf("%v is not a valid UUID.", idStr),
})
continue
}
// Don't check if the ID exists when setting the role to
// WorkspaceRoleDeleted or TemplateRoleDeleted. They might've existing at
// some point and got deleted. If we report that as an error here then they
// can't be removed.
if string(role) == "" {
continue
}
groupIDs = append(groupIDs, id)
}

// Validate that the groups exist
groupValidation, err := db.ValidateGroupIDs(ctx, groupIDs)
if err != nil {
validErrs = append(validErrs, codersdk.ValidationError{
Field: groupsField,
Detail: fmt.Sprintf("failed to validate group IDs: %v", err.Error()),
})
}
if !groupValidation.Ok {
for _, id := range groupValidation.InvalidGroupIds {
validErrs = append(validErrs, codersdk.ValidationError{
Field: groupsField,
Detail: fmt.Sprintf("group with ID %v does not exist", id),
})
}
}

userRoles, usersField := v.Users()
userIDs := make([]uuid.UUID, 0, len(userRoles))
for idStr, role := range userRoles {
// Validate the provided role names
if err := v.ValidateRole(role); err != nil {
validErrs = append(validErrs, codersdk.ValidationError{
Field: usersField,
Detail: err.Error(),
})
}
// Validate that the IDs are UUIDs
id, err := uuid.Parse(idStr)
if err != nil {
validErrs = append(validErrs, codersdk.ValidationError{
Field: usersField,
Detail: fmt.Sprintf("%v is not a valid UUID.", idStr),
})
continue
}
// Don't check if the ID exists when setting the role to
// WorkspaceRoleDeleted or TemplateRoleDeleted. They might've existing at
// some point and got deleted. If we report that as an error here then they
// can't be removed.
if string(role) == "" {
continue
}
userIDs = append(userIDs, id)
}

// Validate that the groups exist
userValidation, err := db.ValidateUserIDs(ctx, userIDs)
if err != nil {
validErrs = append(validErrs, codersdk.ValidationError{
Field: usersField,
Detail: fmt.Sprintf("failed to validate user IDs: %v", err.Error()),
})
}
if !userValidation.Ok {
for _, id := range userValidation.InvalidUserIds {
validErrs = append(validErrs, codersdk.ValidationError{
Field: usersField,
Detail: fmt.Sprintf("user with ID %v does not exist", id),
})
}
}

return validErrs
}
Loading
Loading