From e30250a558526a23b59602c0b1d1122cef5b1bb2 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 6 Aug 2025 01:17:40 +0000 Subject: [PATCH 01/16] refactor: consolidate template and workspace acl validation --- coderd/database/dbauthz/dbauthz.go | 20 ++++ coderd/database/dbmetrics/querymetrics.go | 14 +++ coderd/database/dbmock/dbmock.go | 30 ++++++ coderd/database/querier.go | 2 + coderd/database/queries.sql.go | 63 +++++++++++++ coderd/database/queries/groups.sql | 18 ++++ coderd/database/queries/users.sql | 19 ++++ coderd/rbac/acl/updatevalidator.go | 108 ++++++++++++++++++++++ coderd/workspaces.go | 63 ++++--------- enterprise/coderd/templates.go | 66 +++++-------- 10 files changed, 314 insertions(+), 89 deletions(-) create mode 100644 coderd/rbac/acl/updatevalidator.go diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 99dd9833fa5d6..81a4c0e69e03a 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -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) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index d0cd0d1ab797d..bbed6b55346c8 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -3372,6 +3372,20 @@ func (m queryMetricsStore) UpsertWorkspaceAppAuditSession(ctx context.Context, a return r0, r1 } +func (m queryMetricsStore) ValidateGroupIDs(ctx context.Context, groupIds []uuid.UUID) (database.ValidateGroupIDsRow, error) { + start := time.Now() + r0, r1 := m.s.ValidateGroupIDs(ctx, groupIds) + m.queryLatencies.WithLabelValues("ValidateGroupIDs").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m queryMetricsStore) ValidateUserIDs(ctx context.Context, userIds []uuid.UUID) (database.ValidateUserIDsRow, error) { + start := time.Now() + r0, r1 := m.s.ValidateUserIDs(ctx, userIds) + m.queryLatencies.WithLabelValues("ValidateUserIDs").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetAuthorizedTemplates(ctx context.Context, arg database.GetTemplatesWithFilterParams, prepared rbac.PreparedAuthorized) ([]database.Template, error) { start := time.Now() templates, err := m.s.GetAuthorizedTemplates(ctx, arg, prepared) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index e88763ba1eb74..e1d40f12eb521 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -7159,6 +7159,36 @@ func (mr *MockStoreMockRecorder) UpsertWorkspaceAppAuditSession(ctx, arg any) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertWorkspaceAppAuditSession", reflect.TypeOf((*MockStore)(nil).UpsertWorkspaceAppAuditSession), ctx, arg) } +// ValidateGroupIDs mocks base method. +func (m *MockStore) ValidateGroupIDs(ctx context.Context, groupIds []uuid.UUID) (database.ValidateGroupIDsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ValidateGroupIDs", ctx, groupIds) + ret0, _ := ret[0].(database.ValidateGroupIDsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ValidateGroupIDs indicates an expected call of ValidateGroupIDs. +func (mr *MockStoreMockRecorder) ValidateGroupIDs(ctx, groupIds any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateGroupIDs", reflect.TypeOf((*MockStore)(nil).ValidateGroupIDs), ctx, groupIds) +} + +// ValidateUserIDs mocks base method. +func (m *MockStore) ValidateUserIDs(ctx context.Context, userIds []uuid.UUID) (database.ValidateUserIDsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ValidateUserIDs", ctx, userIds) + ret0, _ := ret[0].(database.ValidateUserIDsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ValidateUserIDs indicates an expected call of ValidateUserIDs. +func (mr *MockStoreMockRecorder) ValidateUserIDs(ctx, userIds any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateUserIDs", reflect.TypeOf((*MockStore)(nil).ValidateUserIDs), ctx, userIds) +} + // Wrappers mocks base method. func (m *MockStore) Wrappers() []string { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index a50bb0bb2192a..1ea4ae5376f80 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -689,6 +689,8 @@ type sqlcQuerier interface { // was started. This means that a new row was inserted (no previous session) or // the updated_at is older than stale interval. UpsertWorkspaceAppAuditSession(ctx context.Context, arg UpsertWorkspaceAppAuditSessionParams) (bool, error) + ValidateGroupIDs(ctx context.Context, groupIds []uuid.UUID) (ValidateGroupIDsRow, error) + ValidateUserIDs(ctx context.Context, userIds []uuid.UUID) (ValidateUserIDsRow, error) } var _ sqlcQuerier = (*sqlQuerier)(nil) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2c1381a3b99f1..25f339a3a0c45 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2869,6 +2869,37 @@ func (q *sqlQuerier) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDPar return i, err } +const validateGroupIDs = `-- name: ValidateGroupIDs :one +WITH input AS ( + SELECT + unnest($1::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... + users + 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 NOT DISTINCT FROM NULL +` + +type ValidateGroupIDsRow struct { + InvalidGroupIds []uuid.UUID `db:"invalid_group_ids" json:"invalid_group_ids"` + Ok bool `db:"ok" json:"ok"` +} + +func (q *sqlQuerier) ValidateGroupIDs(ctx context.Context, groupIds []uuid.UUID) (ValidateGroupIDsRow, error) { + row := q.db.QueryRowContext(ctx, validateGroupIDs, pq.Array(groupIds)) + var i ValidateGroupIDsRow + err := row.Scan(pq.Array(&i.InvalidGroupIds), &i.Ok) + return i, err +} + const getTemplateAppInsights = `-- name: GetTemplateAppInsights :many WITH -- Create a list of all unique apps by template, this is used to @@ -14775,6 +14806,38 @@ func (q *sqlQuerier) UpdateUserThemePreference(ctx context.Context, arg UpdateUs return i, err } +const validateUserIDs = `-- name: ValidateUserIDs :one +WITH input AS ( + SELECT + unnest($1::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 NOT DISTINCT FROM NULL AND + users.deleted = false +` + +type ValidateUserIDsRow struct { + InvalidUserIds []uuid.UUID `db:"invalid_user_ids" json:"invalid_user_ids"` + Ok bool `db:"ok" json:"ok"` +} + +func (q *sqlQuerier) ValidateUserIDs(ctx context.Context, userIds []uuid.UUID) (ValidateUserIDsRow, error) { + row := q.db.QueryRowContext(ctx, validateUserIDs, pq.Array(userIds)) + var i ValidateUserIDsRow + err := row.Scan(pq.Array(&i.InvalidUserIds), &i.Ok) + return i, err +} + const getWorkspaceAgentDevcontainersByAgentID = `-- name: GetWorkspaceAgentDevcontainersByAgentID :many SELECT id, workspace_agent_id, created_at, workspace_folder, config_path, name diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index 48a5ba5c79968..9cb7dda5b7e3e 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -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... + users + 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 NOT DISTINCT FROM NULL; + -- name: GetGroupByOrgAndName :one SELECT * diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index eece2f96512ea..14d5e79bd3727 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -25,6 +25,25 @@ 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 NOT DISTINCT FROM NULL AND + users.deleted = false; + -- 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 diff --git a/coderd/rbac/acl/updatevalidator.go b/coderd/rbac/acl/updatevalidator.go new file mode 100644 index 0000000000000..bee318e725934 --- /dev/null +++ b/coderd/rbac/acl/updatevalidator.go @@ -0,0 +1,108 @@ +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 ACLUpdateValidator[Role codersdk.WorkspaceRole | codersdk.TemplateRole] interface { + Users() (map[string]Role, string) + Groups() (map[string]Role, string) + ValidateRole(role Role) error +} + +func Validate[T codersdk.WorkspaceRole | codersdk.TemplateRole]( + ctx context.Context, + db database.Store, + v ACLUpdateValidator[T], +) []codersdk.ValidationError { + // nolint:gocritic // Validate requires full read access to users and groups + ctx = dbauthz.AsSystemRestricted(ctx) + var validErrs []codersdk.ValidationError + + groupPerms, groupsField := v.Groups() + groupIDs := make([]uuid.UUID, 0, len(groupPerms)) + for idStr, role := range groupPerms { + // 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: idStr + "is not a valid UUID.", + }) + 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), + }) + } + } + + userPerms, usersField := v.Users() + userIDs := make([]uuid.UUID, 0, len(userPerms)) + for idStr, role := range userPerms { + // 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: idStr + "is not a valid UUID.", + }) + 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 +} diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 2080926b44089..76776aef8f002 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -32,6 +32,7 @@ import ( "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/prebuilds" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/acl" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/schedule/cron" @@ -2072,17 +2073,10 @@ func (api *API) patchWorkspaceACL(rw http.ResponseWriter, r *http.Request) { return } - validErrs := validateWorkspaceACLPerms(ctx, api.Database, req.UserRoles, "user_roles") - validErrs = append(validErrs, validateWorkspaceACLPerms( - ctx, - api.Database, - req.GroupRoles, - "group_roles", - )...) - + validErrs := acl.Validate(ctx, api.Database, WorkspaceACLUpdateValidator(req)) if len(validErrs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid request to update template metadata!", + Message: "Invalid request to update workspace ACL", Validations: validErrs, }) return @@ -2478,50 +2472,27 @@ func (api *API) publishWorkspaceAgentLogsUpdate(ctx context.Context, workspaceAg } } -func validateWorkspaceACLPerms(ctx context.Context, db database.Store, perms map[string]codersdk.WorkspaceRole, field string) []codersdk.ValidationError { - // nolint:gocritic // Validate requires full read access to users and groups - ctx = dbauthz.AsSystemRestricted(ctx) - var validErrs []codersdk.ValidationError - for idStr, role := range perms { - if err := validateWorkspaceRole(role); err != nil { - validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: err.Error()}) - continue - } +type WorkspaceACLUpdateValidator codersdk.UpdateWorkspaceACL - id, err := uuid.Parse(idStr) - if err != nil { - validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: idStr + "is not a valid UUID."}) - continue - } +var ( + workspaceACLUpdateUsersFieldName = "user_roles" + workspaceACLUpdateGroupsFieldName = "group_roles" +) - switch field { - case "user_roles": - // TODO(lilac): put this back after Kirby button shenanigans are over - // This could get slow if we get a ton of user perm updates. - // _, err = db.GetUserByID(ctx, id) - // if err != nil { - // validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: fmt.Sprintf("Failed to find resource with ID %q: %v", idStr, err.Error())}) - // continue - // } - case "group_roles": - // This could get slow if we get a ton of group perm updates. - _, err = db.GetGroupByID(ctx, id) - if err != nil { - validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: fmt.Sprintf("Failed to find resource with ID %q: %v", idStr, err.Error())}) - continue - } - default: - validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: "invalid field"}) - } - } +var _ acl.ACLUpdateValidator[codersdk.WorkspaceRole] = WorkspaceACLUpdateValidator{} + +func (w WorkspaceACLUpdateValidator) Users() (map[string]codersdk.WorkspaceRole, string) { + return w.UserRoles, workspaceACLUpdateUsersFieldName +} - return validErrs +func (w WorkspaceACLUpdateValidator) Groups() (map[string]codersdk.WorkspaceRole, string) { + return w.GroupRoles, workspaceACLUpdateGroupsFieldName } -func validateWorkspaceRole(role codersdk.WorkspaceRole) error { +func (WorkspaceACLUpdateValidator) ValidateRole(role codersdk.WorkspaceRole) error { actions := db2sdk.WorkspaceRoleActions(role) if len(actions) == 0 && role != codersdk.WorkspaceRoleDeleted { - return xerrors.Errorf("role %q is not a valid Workspace role", role) + return xerrors.Errorf("role %q is not a valid workspace role", role) } return nil diff --git a/enterprise/coderd/templates.go b/enterprise/coderd/templates.go index 438a7cfd5c65f..cafa00c08bb39 100644 --- a/enterprise/coderd/templates.go +++ b/enterprise/coderd/templates.go @@ -1,7 +1,6 @@ package coderd import ( - "context" "database/sql" "fmt" "net/http" @@ -15,6 +14,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac/acl" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" @@ -208,17 +208,10 @@ func (api *API) patchTemplateACL(rw http.ResponseWriter, r *http.Request) { return } - validErrs := validateTemplateACLPerms(ctx, api.Database, req.UserPerms, "user_perms") - validErrs = append(validErrs, validateTemplateACLPerms( - ctx, - api.Database, - req.GroupPerms, - "group_perms", - )...) - + validErrs := acl.Validate(ctx, api.Database, TemplateACLUpdateValidator(req)) if len(validErrs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid request to update template metadata!", + Message: "Invalid request to update template ACL", Validations: validErrs, }) return @@ -273,43 +266,30 @@ func (api *API) patchTemplateACL(rw http.ResponseWriter, r *http.Request) { }) } -func validateTemplateACLPerms(ctx context.Context, db database.Store, perms map[string]codersdk.TemplateRole, field string) []codersdk.ValidationError { - // nolint:gocritic // Validate requires full read access to users and groups - ctx = dbauthz.AsSystemRestricted(ctx) - var validErrs []codersdk.ValidationError - for idStr, role := range perms { - if err := validateTemplateRole(role); err != nil { - validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: err.Error()}) - continue - } +type TemplateACLUpdateValidator codersdk.UpdateTemplateACL - id, err := uuid.Parse(idStr) - if err != nil { - validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: idStr + "is not a valid UUID."}) - continue - } +var ( + templateACLUpdateUsersFieldName = "user_perms" + templateACLUpdateGroupsFieldName = "group_perms" +) - switch field { - case "user_perms": - // This could get slow if we get a ton of user perm updates. - _, err = db.GetUserByID(ctx, id) - if err != nil { - validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: fmt.Sprintf("Failed to find resource with ID %q: %v", idStr, err.Error())}) - continue - } - case "group_perms": - // This could get slow if we get a ton of group perm updates. - _, err = db.GetGroupByID(ctx, id) - if err != nil { - validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: fmt.Sprintf("Failed to find resource with ID %q: %v", idStr, err.Error())}) - continue - } - default: - validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: "invalid field"}) - } +var _ acl.ACLUpdateValidator[codersdk.TemplateRole] = TemplateACLUpdateValidator{} + +func (w TemplateACLUpdateValidator) Users() (map[string]codersdk.TemplateRole, string) { + return w.UserPerms, templateACLUpdateUsersFieldName +} + +func (w TemplateACLUpdateValidator) Groups() (map[string]codersdk.TemplateRole, string) { + return w.GroupPerms, templateACLUpdateGroupsFieldName +} + +func (TemplateACLUpdateValidator) ValidateRole(role codersdk.TemplateRole) error { + actions := db2sdk.TemplateRoleActions(role) + if len(actions) == 0 && role != codersdk.TemplateRoleDeleted { + return xerrors.Errorf("role %q is not a valid template role", role) } - return validErrs + return nil } func convertTemplateUsers(tus []database.TemplateUser, orgIDsByUserIDs map[uuid.UUID][]uuid.UUID) []codersdk.TemplateUser { From e1d3e3ca88d6df96ac298cd3239cb0e7ed1fd9d1 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 6 Aug 2025 01:23:22 +0000 Subject: [PATCH 02/16] remove unused function --- enterprise/coderd/templates.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/enterprise/coderd/templates.go b/enterprise/coderd/templates.go index cafa00c08bb39..8ca431c7d070e 100644 --- a/enterprise/coderd/templates.go +++ b/enterprise/coderd/templates.go @@ -305,15 +305,6 @@ func convertTemplateUsers(tus []database.TemplateUser, orgIDsByUserIDs map[uuid. return users } -func validateTemplateRole(role codersdk.TemplateRole) error { - actions := db2sdk.TemplateRoleActions(role) - if len(actions) == 0 && role != codersdk.TemplateRoleDeleted { - return xerrors.Errorf("role %q is not a valid Template role", role) - } - - return nil -} - func convertToTemplateRole(actions []policy.Action) codersdk.TemplateRole { switch { case len(actions) == 2 && slice.SameElements(actions, []policy.Action{policy.ActionUse, policy.ActionRead}): From c978adf445273416ff02b80fe6384bd3dadd71e7 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 6 Aug 2025 01:25:50 +0000 Subject: [PATCH 03/16] hello little paren --- coderd/coderd.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 26bf4a7bf9b63..f4cff15bb59ee 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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) }) From 0e108f1953dbe21c0da91be7cb938ca1d397f0b8 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 6 Aug 2025 02:05:26 +0000 Subject: [PATCH 04/16] yeah yeah --- coderd/coderd.go | 2 +- coderd/rbac/acl/updatevalidator.go | 4 ++-- coderd/workspaces.go | 2 +- enterprise/coderd/templates.go | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index f4cff15bb59ee..4168374c06820 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1415,7 +1415,7 @@ 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) diff --git a/coderd/rbac/acl/updatevalidator.go b/coderd/rbac/acl/updatevalidator.go index bee318e725934..e31324456bd00 100644 --- a/coderd/rbac/acl/updatevalidator.go +++ b/coderd/rbac/acl/updatevalidator.go @@ -11,7 +11,7 @@ import ( "github.com/coder/coder/v2/codersdk" ) -type ACLUpdateValidator[Role codersdk.WorkspaceRole | codersdk.TemplateRole] interface { +type UpdateValidator[Role codersdk.WorkspaceRole | codersdk.TemplateRole] interface { Users() (map[string]Role, string) Groups() (map[string]Role, string) ValidateRole(role Role) error @@ -20,7 +20,7 @@ type ACLUpdateValidator[Role codersdk.WorkspaceRole | codersdk.TemplateRole] int func Validate[T codersdk.WorkspaceRole | codersdk.TemplateRole]( ctx context.Context, db database.Store, - v ACLUpdateValidator[T], + v UpdateValidator[T], ) []codersdk.ValidationError { // nolint:gocritic // Validate requires full read access to users and groups ctx = dbauthz.AsSystemRestricted(ctx) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 76776aef8f002..4668c1360fb7b 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -2479,7 +2479,7 @@ var ( workspaceACLUpdateGroupsFieldName = "group_roles" ) -var _ acl.ACLUpdateValidator[codersdk.WorkspaceRole] = WorkspaceACLUpdateValidator{} +var _ acl.UpdateValidator[codersdk.WorkspaceRole] = WorkspaceACLUpdateValidator{} func (w WorkspaceACLUpdateValidator) Users() (map[string]codersdk.WorkspaceRole, string) { return w.UserRoles, workspaceACLUpdateUsersFieldName diff --git a/enterprise/coderd/templates.go b/enterprise/coderd/templates.go index 8ca431c7d070e..b0d9b3a13d578 100644 --- a/enterprise/coderd/templates.go +++ b/enterprise/coderd/templates.go @@ -273,7 +273,7 @@ var ( templateACLUpdateGroupsFieldName = "group_perms" ) -var _ acl.ACLUpdateValidator[codersdk.TemplateRole] = TemplateACLUpdateValidator{} +var _ acl.UpdateValidator[codersdk.TemplateRole] = TemplateACLUpdateValidator{} func (w TemplateACLUpdateValidator) Users() (map[string]codersdk.TemplateRole, string) { return w.UserPerms, templateACLUpdateUsersFieldName From 3a7c8e66ea49ef1abe7b53f385d55c5b4b0c5e73 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 6 Aug 2025 18:33:40 +0000 Subject: [PATCH 05/16] commit of shame --- coderd/database/queries/groups.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index 9cb7dda5b7e3e..fb9f5566a80ba 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -19,7 +19,7 @@ SELECT FROM -- Preserve rows where there is not a matching left (groups) row for each -- right (input) row... - users + groups RIGHT JOIN input ON groups.id = input.id WHERE -- ...so that we can retain exactly those rows where an input ID does not From 7ed357c555a97e0c368aca9af4ea5f46a524f955 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 6 Aug 2025 18:46:12 +0000 Subject: [PATCH 06/16] commit of shame part 2 --- coderd/database/queries.sql.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 25f339a3a0c45..1ed531c3f9f13 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2880,7 +2880,7 @@ SELECT FROM -- Preserve rows where there is not a matching left (groups) row for each -- right (input) row... - users + groups RIGHT JOIN input ON groups.id = input.id WHERE -- ...so that we can retain exactly those rows where an input ID does not From 468cd79033f4c859a5f266f24cb85923f31a46cf Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 6 Aug 2025 18:46:16 +0000 Subject: [PATCH 07/16] comments --- coderd/rbac/acl/updatevalidator.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/coderd/rbac/acl/updatevalidator.go b/coderd/rbac/acl/updatevalidator.go index e31324456bd00..796a1bb546bd7 100644 --- a/coderd/rbac/acl/updatevalidator.go +++ b/coderd/rbac/acl/updatevalidator.go @@ -12,8 +12,16 @@ import ( ) 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 } From 5ffa8f9a623b2e91b91d4a23e26570bd3ccf9df1 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 6 Aug 2025 19:35:48 +0000 Subject: [PATCH 08/16] tests! --- coderd/database/queries.sql.go | 7 ++- coderd/database/queries/users.sql | 7 ++- coderd/rbac/acl/updatevalidator.go | 30 +++++++--- enterprise/coderd/templates_test.go | 89 ++++++++++++++++++++++++++++- 4 files changed, 118 insertions(+), 15 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 1ed531c3f9f13..a5386a607243c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -14821,9 +14821,10 @@ FROM 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 NOT DISTINCT FROM NULL AND - users.deleted = false + -- match an existing user... + users.id IS NOT DISTINCT FROM NULL OR + -- ...or that only matches a user that was deleted. + users.deleted = true ` type ValidateUserIDsRow struct { diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 14d5e79bd3727..14024bd03981a 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -40,9 +40,10 @@ FROM 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 NOT DISTINCT FROM NULL AND - users.deleted = false; + -- match an existing user... + users.id IS NOT DISTINCT FROM 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 diff --git a/coderd/rbac/acl/updatevalidator.go b/coderd/rbac/acl/updatevalidator.go index 796a1bb546bd7..5082bea082416 100644 --- a/coderd/rbac/acl/updatevalidator.go +++ b/coderd/rbac/acl/updatevalidator.go @@ -34,9 +34,9 @@ func Validate[T codersdk.WorkspaceRole | codersdk.TemplateRole]( ctx = dbauthz.AsSystemRestricted(ctx) var validErrs []codersdk.ValidationError - groupPerms, groupsField := v.Groups() - groupIDs := make([]uuid.UUID, 0, len(groupPerms)) - for idStr, role := range groupPerms { + 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{ @@ -49,10 +49,17 @@ func Validate[T codersdk.WorkspaceRole | codersdk.TemplateRole]( if err != nil { validErrs = append(validErrs, codersdk.ValidationError{ Field: groupsField, - Detail: idStr + "is not a valid UUID.", + 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) } @@ -73,9 +80,9 @@ func Validate[T codersdk.WorkspaceRole | codersdk.TemplateRole]( } } - userPerms, usersField := v.Users() - userIDs := make([]uuid.UUID, 0, len(userPerms)) - for idStr, role := range userPerms { + 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{ @@ -88,10 +95,17 @@ func Validate[T codersdk.WorkspaceRole | codersdk.TemplateRole]( if err != nil { validErrs = append(validErrs, codersdk.ValidationError{ Field: usersField, - Detail: idStr + "is not a valid UUID.", + 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) } diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 6c7a20f85a642..e111874fd7490 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -1413,7 +1413,34 @@ func TestUpdateTemplateACL(t *testing.T) { template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) req := codersdk.UpdateTemplateACL{ UserPerms: map[string]codersdk.TemplateRole{ - "hi": "admin", + "hi": codersdk.TemplateRoleAdmin, + }, + } + + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // we're testing invalid UUID so testing RBAC is not relevant here. + err := client.UpdateTemplateACL(ctx, template.ID, req) + require.Error(t, err) + cerr, _ := codersdk.AsError(err) + require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) + }) + + // We should report invalid UUIDs as errors + t.Run("DeleteRoleForInvalidUUID", func(t *testing.T) { + t.Parallel() + + client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }}) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + req := codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + "hi": codersdk.TemplateRoleDeleted, }, } @@ -1452,6 +1479,66 @@ func TestUpdateTemplateACL(t *testing.T) { require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) }) + // We should allow the special "Delete" role for valid UUIDs that don't + // correspond to a valid user, because the user might have been deleted. + t.Run("DeleteRoleForDeletedUser", func(t *testing.T) { + t.Parallel() + + client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }}) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx := testutil.Context(t, testutil.WaitLong) + + _, deletedUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + err := client.DeleteUser(ctx, deletedUser.ID) + require.NoError(t, err) + + req := codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + deletedUser.ID.String(): codersdk.TemplateRoleDeleted, + }, + } + //nolint:gocritic // Testing ACL validation + err = client.UpdateTemplateACL(ctx, template.ID, req) + require.NoError(t, err) + }) + + t.Run("DeletedUser", func(t *testing.T) { + t.Parallel() + + client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }}) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx := testutil.Context(t, testutil.WaitLong) + + _, deletedUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + err := client.DeleteUser(ctx, deletedUser.ID) + require.NoError(t, err) + + req := codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + deletedUser.ID.String(): codersdk.TemplateRoleAdmin, + }, + } + //nolint:gocritic // Testing ACL validation + err = client.UpdateTemplateACL(ctx, template.ID, req) + require.Error(t, err) + cerr, _ := codersdk.AsError(err) + require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) + }) + t.Run("InvalidRole", func(t *testing.T) { t.Parallel() From 1e10aa03ca8e94faa5fd56027f34b0e47200c450 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 6 Aug 2025 19:42:41 +0000 Subject: [PATCH 09/16] actually it only yells about dbauthz, which I can just fix huh --- coderd/database/dbauthz/dbauthz.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 81a4c0e69e03a..4e752399e08eb 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -5376,24 +5376,24 @@ 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) { +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) + return q.db.ValidateGroupIDs(ctx, groupIDs) } -func (q *querier) ValidateUserIDs(ctx context.Context, userIds []uuid.UUID) (database.ValidateUserIDsRow, error) { +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) + return q.db.ValidateUserIDs(ctx, userIDs) } func (q *querier) GetAuthorizedTemplates(ctx context.Context, arg database.GetTemplatesWithFilterParams, _ rbac.PreparedAuthorized) ([]database.Template, error) { From e38dcebf6676205da35c6a3662c0dc9490e2dc9c Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 6 Aug 2025 19:44:18 +0000 Subject: [PATCH 10/16] :) --- enterprise/coderd/templates_test.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index e111874fd7490..d95450e28e8aa 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -1419,7 +1419,7 @@ func TestUpdateTemplateACL(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - //nolint:gocritic // we're testing invalid UUID so testing RBAC is not relevant here. + //nolint:gocritic // Testing ACL validation err := client.UpdateTemplateACL(ctx, template.ID, req) require.Error(t, err) cerr, _ := codersdk.AsError(err) @@ -1446,7 +1446,7 @@ func TestUpdateTemplateACL(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - //nolint:gocritic // we're testing invalid UUID so testing RBAC is not relevant here. + //nolint:gocritic // Testing ACL validation err := client.UpdateTemplateACL(ctx, template.ID, req) require.Error(t, err) cerr, _ := codersdk.AsError(err) @@ -1472,7 +1472,7 @@ func TestUpdateTemplateACL(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - //nolint:gocritic // we're testing invalid user so testing RBAC is not relevant here. + //nolint:gocritic // Testing ACL validation err := client.UpdateTemplateACL(ctx, template.ID, req) require.Error(t, err) cerr, _ := codersdk.AsError(err) @@ -1496,6 +1496,7 @@ func TestUpdateTemplateACL(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) _, deletedUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + //nolint:gocritic // Can't delete yourself err := client.DeleteUser(ctx, deletedUser.ID) require.NoError(t, err) @@ -1524,6 +1525,7 @@ func TestUpdateTemplateACL(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) _, deletedUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + //nolint:gocritic // Can't delete yourself err := client.DeleteUser(ctx, deletedUser.ID) require.NoError(t, err) @@ -1559,7 +1561,7 @@ func TestUpdateTemplateACL(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - //nolint:gocritic // we're testing invalid role so testing RBAC is not relevant here. + //nolint:gocritic // Testing ACL validation err := client.UpdateTemplateACL(ctx, template.ID, req) require.Error(t, err) cerr, _ := codersdk.AsError(err) From 03125e3ce975c06d185bad496d79239f910548b9 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 6 Aug 2025 20:09:50 +0000 Subject: [PATCH 11/16] `IS NULL` is actually fine --- coderd/database/queries.sql.go | 4 ++-- coderd/database/queries/groups.sql | 2 +- coderd/database/queries/users.sql | 2 +- coderd/rbac/acl/updatevalidator.go | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index a5386a607243c..4c3a81fc879fc 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2885,7 +2885,7 @@ FROM WHERE -- ...so that we can retain exactly those rows where an input ID does not -- match an existing group. - groups.id IS NOT DISTINCT FROM NULL + groups.id IS NULL ` type ValidateGroupIDsRow struct { @@ -14822,7 +14822,7 @@ FROM WHERE -- ...so that we can retain exactly those rows where an input ID does not -- match an existing user... - users.id IS NOT DISTINCT FROM NULL OR + users.id IS NULL OR -- ...or that only matches a user that was deleted. users.deleted = true ` diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index fb9f5566a80ba..3413e5832e27d 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -24,7 +24,7 @@ FROM WHERE -- ...so that we can retain exactly those rows where an input ID does not -- match an existing group. - groups.id IS NOT DISTINCT FROM NULL; + groups.id IS NULL; -- name: GetGroupByOrgAndName :one SELECT diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 14024bd03981a..0b6e52d6bc918 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -41,7 +41,7 @@ FROM WHERE -- ...so that we can retain exactly those rows where an input ID does not -- match an existing user... - users.id IS NOT DISTINCT FROM NULL OR + users.id IS NULL OR -- ...or that only matches a user that was deleted. users.deleted = true; diff --git a/coderd/rbac/acl/updatevalidator.go b/coderd/rbac/acl/updatevalidator.go index 5082bea082416..9785609f2e33a 100644 --- a/coderd/rbac/acl/updatevalidator.go +++ b/coderd/rbac/acl/updatevalidator.go @@ -25,10 +25,10 @@ type UpdateValidator[Role codersdk.WorkspaceRole | codersdk.TemplateRole] interf ValidateRole(role Role) error } -func Validate[T codersdk.WorkspaceRole | codersdk.TemplateRole]( +func Validate[Role codersdk.WorkspaceRole | codersdk.TemplateRole]( ctx context.Context, db database.Store, - v UpdateValidator[T], + v UpdateValidator[Role], ) []codersdk.ValidationError { // nolint:gocritic // Validate requires full read access to users and groups ctx = dbauthz.AsSystemRestricted(ctx) From bfe31b8b2791952257e17a12fc134d6bd46a5937 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 6 Aug 2025 21:48:52 +0000 Subject: [PATCH 12/16] dbauthz tests --- coderd/database/dbauthz/dbauthz_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 66a477ebfbaba..b396dcabceff1 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -623,6 +623,10 @@ func (s *MethodTestSuite) TestGroup() { ID: g.ID, }).Asserts(g, policy.ActionUpdate) })) + s.Run("ValidateGroupIDs", s.Subtest(func(db database.Store, check *expects) { + g := dbgen.Group(s.T(), db, database.Group{}) + check.Args([]uuid.UUID{g.ID}).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) } func (s *MethodTestSuite) TestProvisionerJob() { @@ -2077,6 +2081,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() { From e3686061b8342e0b7433863df561a97d543c4c02 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 6 Aug 2025 21:52:28 +0000 Subject: [PATCH 13/16] no you're a foreign key --- coderd/database/dbauthz/dbauthz_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index b396dcabceff1..deca01456244f 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -624,7 +624,8 @@ func (s *MethodTestSuite) TestGroup() { }).Asserts(g, policy.ActionUpdate) })) s.Run("ValidateGroupIDs", s.Subtest(func(db database.Store, check *expects) { - g := dbgen.Group(s.T(), db, database.Group{}) + 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) })) } From 5e3f8589fb7e372c72cbf1b0cae547db66db1db7 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 6 Aug 2025 22:24:58 +0000 Subject: [PATCH 14/16] more testing --- coderd/rbac/acl/updatevalidator_test.go | 84 +++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 coderd/rbac/acl/updatevalidator_test.go diff --git a/coderd/rbac/acl/updatevalidator_test.go b/coderd/rbac/acl/updatevalidator_test.go new file mode 100644 index 0000000000000..cad3331ac94e6 --- /dev/null +++ b/coderd/rbac/acl/updatevalidator_test.go @@ -0,0 +1,84 @@ +package acl_test + +import ( + "testing" + + "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/rbac/acl" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestOK(t *testing.T) { + db, _ := dbtestutil.NewDB(t, dbtestutil.WithTimezone("UTC")) + o := dbgen.Organization(t, db, database.Organization{}) + g := dbgen.Group(t, db, database.Group{OrganizationID: o.ID}) + u := dbgen.User(t, db, database.User{}) + ctx := testutil.Context(t, testutil.WaitShort) + + update := codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + u.ID.String(): codersdk.WorkspaceRoleAdmin, + // An unknown ID is allowed if and only if the specified role is either + // codersdk.WorkspaceRoleDeleted or codersdk.TemplateRoleDeleted. + uuid.NewString(): codersdk.WorkspaceRoleDeleted, + }, + GroupRoles: map[string]codersdk.WorkspaceRole{ + g.ID.String(): codersdk.WorkspaceRoleAdmin, + // An unknown ID is allowed if and only if the specified role is either + // codersdk.WorkspaceRoleDeleted or codersdk.TemplateRoleDeleted. + uuid.NewString(): codersdk.WorkspaceRoleDeleted, + }, + } + errors := acl.Validate(ctx, db, coderd.WorkspaceACLUpdateValidator(update)) + require.Empty(t, errors) +} + +func TestDeniesUnknownIDs(t *testing.T) { + db, _ := dbtestutil.NewDB(t, dbtestutil.WithTimezone("UTC")) + ctx := testutil.Context(t, testutil.WaitShort) + + update := codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + uuid.NewString(): codersdk.WorkspaceRoleAdmin, + }, + GroupRoles: map[string]codersdk.WorkspaceRole{ + uuid.NewString(): codersdk.WorkspaceRoleAdmin, + }, + } + errors := acl.Validate(ctx, db, coderd.WorkspaceACLUpdateValidator(update)) + require.Len(t, errors, 2) + require.Equal(t, errors[0].Field, "group_roles") + require.ErrorContains(t, errors[0], "does not exist") + require.Equal(t, errors[1].Field, "user_roles") + require.ErrorContains(t, errors[1], "does not exist") +} + +func TestDeniesUnknownRolesAndInvalidIDs(t *testing.T) { + db, _ := dbtestutil.NewDB(t, dbtestutil.WithTimezone("UTC")) + ctx := testutil.Context(t, testutil.WaitShort) + + update := codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + "Quifrey": "level 5", + }, + GroupRoles: map[string]codersdk.WorkspaceRole{ + "apprentices": "level 2", + }, + } + errors := acl.Validate(ctx, db, coderd.WorkspaceACLUpdateValidator(update)) + require.Len(t, errors, 4) + require.Equal(t, errors[0].Field, "group_roles") + require.ErrorContains(t, errors[0], "role \"level 2\" is not a valid workspace role") + require.Equal(t, errors[1].Field, "group_roles") + require.ErrorContains(t, errors[1], "not a valid UUID") + require.Equal(t, errors[2].Field, "user_roles") + require.ErrorContains(t, errors[2], "role \"level 5\" is not a valid workspace role") + require.Equal(t, errors[3].Field, "user_roles") + require.ErrorContains(t, errors[3], "not a valid UUID") +} From 4a162dfc603e3838f767fb05841e79b9c6673633 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 6 Aug 2025 22:28:32 +0000 Subject: [PATCH 15/16] lint --- coderd/rbac/acl/updatevalidator_test.go | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/coderd/rbac/acl/updatevalidator_test.go b/coderd/rbac/acl/updatevalidator_test.go index cad3331ac94e6..0e394370b1356 100644 --- a/coderd/rbac/acl/updatevalidator_test.go +++ b/coderd/rbac/acl/updatevalidator_test.go @@ -3,6 +3,9 @@ package acl_test import ( "testing" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" @@ -10,12 +13,12 @@ import ( "github.com/coder/coder/v2/coderd/rbac/acl" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" - "github.com/google/uuid" - "github.com/stretchr/testify/require" ) func TestOK(t *testing.T) { - db, _ := dbtestutil.NewDB(t, dbtestutil.WithTimezone("UTC")) + t.Parallel() + + db, _ := dbtestutil.NewDB(t) o := dbgen.Organization(t, db, database.Organization{}) g := dbgen.Group(t, db, database.Group{OrganizationID: o.ID}) u := dbgen.User(t, db, database.User{}) @@ -40,7 +43,9 @@ func TestOK(t *testing.T) { } func TestDeniesUnknownIDs(t *testing.T) { - db, _ := dbtestutil.NewDB(t, dbtestutil.WithTimezone("UTC")) + t.Parallel() + + db, _ := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitShort) update := codersdk.UpdateWorkspaceACL{ @@ -60,7 +65,9 @@ func TestDeniesUnknownIDs(t *testing.T) { } func TestDeniesUnknownRolesAndInvalidIDs(t *testing.T) { - db, _ := dbtestutil.NewDB(t, dbtestutil.WithTimezone("UTC")) + t.Parallel() + + db, _ := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitShort) update := codersdk.UpdateWorkspaceACL{ From 48dd824df3436ff905ce9272719ffb65852c2100 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 6 Aug 2025 22:40:14 +0000 Subject: [PATCH 16/16] nice lil comments --- coderd/workspaces.go | 1 + enterprise/coderd/templates.go | 1 + 2 files changed, 2 insertions(+) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 4668c1360fb7b..a4405e38cc233 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -2479,6 +2479,7 @@ var ( workspaceACLUpdateGroupsFieldName = "group_roles" ) +// WorkspaceACLUpdateValidator implements acl.UpdateValidator[codersdk.WorkspaceRole] var _ acl.UpdateValidator[codersdk.WorkspaceRole] = WorkspaceACLUpdateValidator{} func (w WorkspaceACLUpdateValidator) Users() (map[string]codersdk.WorkspaceRole, string) { diff --git a/enterprise/coderd/templates.go b/enterprise/coderd/templates.go index b0d9b3a13d578..07323dce3c7e6 100644 --- a/enterprise/coderd/templates.go +++ b/enterprise/coderd/templates.go @@ -273,6 +273,7 @@ var ( templateACLUpdateGroupsFieldName = "group_perms" ) +// TemplateACLUpdateValidator implements acl.UpdateValidator[codersdk.TemplateRole] var _ acl.UpdateValidator[codersdk.TemplateRole] = TemplateACLUpdateValidator{} func (w TemplateACLUpdateValidator) Users() (map[string]codersdk.TemplateRole, string) {