From 9a9deeb8dbb17d704712e928283bd88a1af7709c Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 15 Jun 2026 16:52:00 +0000 Subject: [PATCH 1/2] fix(coderd/rbac): guard builtInRoles with atomic.Pointer The built-in roles map is a package-level global that ReloadBuiltinRoles overwrites whenever a coderd starts up. Production reads it once at init, so the race was latent; enterprise tests spin many coderds in parallel while live handlers call RoleByName, so test-go-race-pg started failing with a write-vs-read race after PR #25994 added a new RoleByName lookup inside patchOrganization. Wrap the map in atomic.Pointer so readers see a consistent snapshot without locking. ReloadBuiltinRoles builds the map locally, then publishes it with Store. Matches the existing atomic.Bool pattern used for minimumImplicitMember. Fixes coder/internal#1575. --- coderd/rbac/roles.go | 33 ++++++++++++++++++++++++------ coderd/rbac/roles_internal_test.go | 26 +++++++++++------------ 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index c67f3f22cc1aa..6b4f5266202e6 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -7,6 +7,7 @@ import ( "sort" "strconv" "strings" + "sync/atomic" "github.com/google/uuid" "github.com/open-policy-agent/opa/ast" @@ -314,7 +315,25 @@ func allPermsExcept(excepts ...Objecter) []Permission { // // This map will be replaced by database storage defined by this ticket. // https://github.com/coder/coder/issues/1194 -var builtInRoles map[string]func(orgID uuid.UUID) Role +// +// Stored behind an atomic.Pointer so test setups that call +// ReloadBuiltinRoles do not race with handlers that look up roles via +// RoleByName, ReservedRoleName, OrganizationRoles, or SiteBuiltInRoles. +// Production callers reload once at startup; tests reload per coderd. +type builtInRoleMap = map[string]func(orgID uuid.UUID) Role + +var builtInRoles atomic.Pointer[builtInRoleMap] + +// loadBuiltinRoles returns the current built-in roles snapshot. The +// returned map is safe to read concurrently because ReloadBuiltinRoles +// publishes a fresh map via atomic.Pointer.Store instead of mutating in +// place. +func loadBuiltinRoles() builtInRoleMap { + if m := builtInRoles.Load(); m != nil { + return *m + } + return nil +} type RoleOptions struct { NoOwnerWorkspaceExec bool @@ -333,7 +352,7 @@ type RoleOptions struct { // ReservedRoleName exists because the database should only allow unique role // names, but some roles are built in. So these names are reserved func ReservedRoleName(name string) bool { - _, ok := builtInRoles[name] + _, ok := loadBuiltinRoles()[name] return ok } @@ -515,7 +534,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { ByOrgID: map[string]OrgPermissions{}, }.withCachedRegoValue() - builtInRoles = map[string]func(orgID uuid.UUID) Role{ + roles := builtInRoleMap{ // admin grants all actions to all resources. owner: func(_ uuid.UUID) Role { return ownerRole @@ -733,6 +752,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) { } }, } + + builtInRoles.Store(&roles) } // assignRoles is a map of roles that can be assigned if a user has a given @@ -954,7 +975,7 @@ func CanAssignRole(subjectHasRoles ExpandableRoles, assignedRole RoleIdentifier) // api. We should maybe make an exported function that returns just the // human-readable content of the Role struct (name + display name). func RoleByName(name RoleIdentifier) (Role, error) { - roleFunc, ok := builtInRoles[name.Name] + roleFunc, ok := loadBuiltinRoles()[name.Name] if !ok { // No role found return Role{}, xerrors.Errorf("role %q not found", name.String()) @@ -997,7 +1018,7 @@ func rolesByNames(roleNames []RoleIdentifier) ([]Role, error) { // the list from the builtins. func OrganizationRoles(organizationID uuid.UUID) []Role { var roles []Role - for _, roleF := range builtInRoles { + for _, roleF := range loadBuiltinRoles() { role := roleF(organizationID) if role.Identifier.OrganizationID == organizationID { roles = append(roles, role) @@ -1013,7 +1034,7 @@ func OrganizationRoles(organizationID uuid.UUID) []Role { // the list from the builtins. func SiteBuiltInRoles() []Role { var roles []Role - for _, roleF := range builtInRoles { + for _, roleF := range loadBuiltinRoles() { // Must provide some non-nil uuid to filter out org roles. role := roleF(uuid.New()) if !role.Identifier.IsOrgRole() { diff --git a/coderd/rbac/roles_internal_test.go b/coderd/rbac/roles_internal_test.go index c45760f653365..715071e1b4d53 100644 --- a/coderd/rbac/roles_internal_test.go +++ b/coderd/rbac/roles_internal_test.go @@ -215,19 +215,19 @@ func TestRoleByName(t *testing.T) { testCases := []struct { Role Role }{ - {Role: builtInRoles[owner](uuid.Nil)}, - {Role: builtInRoles[member](uuid.Nil)}, - {Role: builtInRoles[templateAdmin](uuid.Nil)}, - {Role: builtInRoles[userAdmin](uuid.Nil)}, - {Role: builtInRoles[auditor](uuid.Nil)}, - - {Role: builtInRoles[orgAdmin](uuid.New())}, - {Role: builtInRoles[orgAdmin](uuid.New())}, - {Role: builtInRoles[orgAdmin](uuid.New())}, - - {Role: builtInRoles[orgAuditor](uuid.New())}, - {Role: builtInRoles[orgAuditor](uuid.New())}, - {Role: builtInRoles[orgAuditor](uuid.New())}, + {Role: loadBuiltinRoles()[owner](uuid.Nil)}, + {Role: loadBuiltinRoles()[member](uuid.Nil)}, + {Role: loadBuiltinRoles()[templateAdmin](uuid.Nil)}, + {Role: loadBuiltinRoles()[userAdmin](uuid.Nil)}, + {Role: loadBuiltinRoles()[auditor](uuid.Nil)}, + + {Role: loadBuiltinRoles()[orgAdmin](uuid.New())}, + {Role: loadBuiltinRoles()[orgAdmin](uuid.New())}, + {Role: loadBuiltinRoles()[orgAdmin](uuid.New())}, + + {Role: loadBuiltinRoles()[orgAuditor](uuid.New())}, + {Role: loadBuiltinRoles()[orgAuditor](uuid.New())}, + {Role: loadBuiltinRoles()[orgAuditor](uuid.New())}, } for _, c := range testCases { From c19c8be4131492d7eb4c9828db509ca7dab09796 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 15 Jun 2026 12:21:04 -0500 Subject: [PATCH 2/2] fix nil pointer deref possibility --- coderd/rbac/roles.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 6b4f5266202e6..4404c071f2dd3 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -332,7 +332,8 @@ func loadBuiltinRoles() builtInRoleMap { if m := builtInRoles.Load(); m != nil { return *m } - return nil + // Return an empty map to prevent nil pointer dereference + return map[string]func(orgID uuid.UUID) Role{} } type RoleOptions struct {