feat(coderd/rbac): make organization-member a per-org system custom role (#21359)

Migrated the built-in organization-member role to DB storage so it can be customized per org.

Closes https://github.com/coder/internal/issues/1073 (part 1)
This commit is contained in:
George K
2026-01-12 18:19:19 -08:00
committed by GitHub
parent 2b448c7178
commit cc2efe9e1f
46 changed files with 1845 additions and 438 deletions
+235 -6
View File
@@ -73,6 +73,7 @@ func TestInsertCustomRoles(t *testing.T) {
site []codersdk.Permission
org []codersdk.Permission
user []codersdk.Permission
member []codersdk.Permission
errorContains string
}{
{
@@ -171,6 +172,16 @@ func TestInsertCustomRoles(t *testing.T) {
}),
errorContains: "organization roles specify site or user permissions",
},
{
// Not allowing these at this time.
name: "member-permissions",
organizationID: orgID,
subject: merge(canCreateCustomRole),
member: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}),
errorContains: "non-system roles specify member permissions",
},
{
name: "site-escalation",
organizationID: orgID,
@@ -213,12 +224,13 @@ func TestInsertCustomRoles(t *testing.T) {
ctx = dbauthz.As(ctx, subject)
_, err := az.InsertCustomRole(ctx, database.InsertCustomRoleParams{
Name: "test-role",
DisplayName: "",
OrganizationID: uuid.NullUUID{UUID: tc.organizationID, Valid: true},
SitePermissions: db2sdk.List(tc.site, convertSDKPerm),
OrgPermissions: db2sdk.List(tc.org, convertSDKPerm),
UserPermissions: db2sdk.List(tc.user, convertSDKPerm),
Name: "test-role",
DisplayName: "",
OrganizationID: uuid.NullUUID{UUID: tc.organizationID, Valid: true},
SitePermissions: db2sdk.List(tc.site, convertSDKPerm),
OrgPermissions: db2sdk.List(tc.org, convertSDKPerm),
UserPermissions: db2sdk.List(tc.user, convertSDKPerm),
MemberPermissions: db2sdk.List(tc.member, convertSDKPerm),
})
if tc.errorContains != "" {
require.ErrorContains(t, err, tc.errorContains)
@@ -250,3 +262,220 @@ func convertSDKPerm(perm codersdk.Permission) database.CustomRolePermission {
Action: policy.Action(perm.Action),
}
}
func TestSystemRoles(t *testing.T) {
t.Parallel()
orgID := uuid.New()
canManageOrgRoles := rbac.Role{
Identifier: rbac.RoleIdentifier{Name: "can-manage-org-roles"},
DisplayName: "",
Site: rbac.Permissions(map[string][]policy.Action{
rbac.ResourceAssignOrgRole.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionUpdate},
}),
}
canCreateSystem := rbac.Role{
Identifier: rbac.RoleIdentifier{Name: "can-create-system"},
DisplayName: "",
Site: rbac.Permissions(map[string][]policy.Action{
rbac.ResourceSystem.Type: {policy.ActionCreate},
}),
}
canUpdateSystem := rbac.Role{
Identifier: rbac.RoleIdentifier{Name: "can-update-system"},
DisplayName: "",
Site: rbac.Permissions(map[string][]policy.Action{
rbac.ResourceSystem.Type: {policy.ActionUpdate},
}),
}
userID := uuid.New()
subjectNoSystemPerms := rbac.Subject{
FriendlyName: "Test user",
ID: userID.String(),
Roles: rbac.Roles([]rbac.Role{canManageOrgRoles}),
Groups: nil,
Scope: rbac.ScopeAll,
}
subjectWithSystemCreatePerms := subjectNoSystemPerms
subjectWithSystemCreatePerms.Roles = rbac.Roles([]rbac.Role{canManageOrgRoles, canCreateSystem})
subjectWithSystemUpdatePerms := subjectNoSystemPerms
subjectWithSystemUpdatePerms.Roles = rbac.Roles([]rbac.Role{canManageOrgRoles, canUpdateSystem})
db, _ := dbtestutil.NewDB(t)
rec := &coderdtest.RecordingAuthorizer{
Wrapped: rbac.NewAuthorizer(prometheus.NewRegistry()),
}
az := dbauthz.New(db, rec, slog.Make(), coderdtest.AccessControlStorePointer())
t.Run("insert-requires-system-create", func(t *testing.T) {
t.Parallel()
insertParamsTemplate := database.InsertCustomRoleParams{
Name: "",
OrganizationID: uuid.NullUUID{
UUID: orgID,
Valid: true,
},
SitePermissions: database.CustomRolePermissions{},
OrgPermissions: database.CustomRolePermissions{},
UserPermissions: database.CustomRolePermissions{},
MemberPermissions: database.CustomRolePermissions{},
IsSystem: true,
}
t.Run("deny-no-system-perms", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
insertParams := insertParamsTemplate
insertParams.Name = "test-system-role-" + uuid.NewString()
ctx = dbauthz.As(ctx, subjectNoSystemPerms)
_, err := az.InsertCustomRole(ctx, insertParams)
require.ErrorContains(t, err, "forbidden")
})
t.Run("deny-update-only", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
insertParams := insertParamsTemplate
insertParams.Name = "test-system-role-" + uuid.NewString()
ctx = dbauthz.As(ctx, subjectWithSystemUpdatePerms)
_, err := az.InsertCustomRole(ctx, insertParams)
require.ErrorContains(t, err, "forbidden")
})
t.Run("allow-create-only", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
insertParams := insertParamsTemplate
insertParams.Name = "test-system-role-" + uuid.NewString()
ctx = dbauthz.As(ctx, subjectWithSystemCreatePerms)
_, err := az.InsertCustomRole(ctx, insertParams)
require.NoError(t, err)
})
})
t.Run("update-requires-system-update", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
ctx = dbauthz.As(ctx, subjectWithSystemCreatePerms)
// Setup: create the role that we will attempt to update in
// subtests. One role for all is fine as we are only testing
// authz.
role, err := az.InsertCustomRole(ctx, database.InsertCustomRoleParams{
Name: "test-system-role-" + uuid.NewString(),
OrganizationID: uuid.NullUUID{
UUID: orgID,
Valid: true,
},
SitePermissions: database.CustomRolePermissions{},
OrgPermissions: database.CustomRolePermissions{},
UserPermissions: database.CustomRolePermissions{},
MemberPermissions: database.CustomRolePermissions{},
IsSystem: true,
})
require.NoError(t, err)
// Use same params for all updates as we're only testing authz.
updateParams := database.UpdateCustomRoleParams{
Name: role.Name,
OrganizationID: uuid.NullUUID{
UUID: orgID,
Valid: true,
},
DisplayName: "",
SitePermissions: database.CustomRolePermissions{},
OrgPermissions: database.CustomRolePermissions{},
UserPermissions: database.CustomRolePermissions{},
MemberPermissions: database.CustomRolePermissions{},
}
t.Run("deny-no-system-perms", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
ctx = dbauthz.As(ctx, subjectNoSystemPerms)
_, err := az.UpdateCustomRole(ctx, updateParams)
require.ErrorContains(t, err, "forbidden")
})
t.Run("deny-create-only", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
ctx = dbauthz.As(ctx, subjectWithSystemCreatePerms)
_, err := az.UpdateCustomRole(ctx, updateParams)
require.ErrorContains(t, err, "forbidden")
})
t.Run("allow-update-only", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
ctx = dbauthz.As(ctx, subjectWithSystemUpdatePerms)
_, err := az.UpdateCustomRole(ctx, updateParams)
require.NoError(t, err)
})
})
t.Run("allow-member-permissions", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
ctx = dbauthz.As(ctx, subjectWithSystemCreatePerms)
_, err := az.InsertCustomRole(ctx, database.InsertCustomRoleParams{
Name: "test-system-role-member-perms",
OrganizationID: uuid.NullUUID{
UUID: orgID,
Valid: true,
},
SitePermissions: database.CustomRolePermissions{},
OrgPermissions: database.CustomRolePermissions{},
UserPermissions: database.CustomRolePermissions{},
MemberPermissions: database.CustomRolePermissions{
{
ResourceType: rbac.ResourceWorkspace.Type,
Action: policy.ActionRead,
},
},
IsSystem: true,
})
require.NoError(t, err)
})
t.Run("allow-negative-permissions", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
ctx = dbauthz.As(ctx, subjectWithSystemCreatePerms)
_, err := az.InsertCustomRole(ctx, database.InsertCustomRoleParams{
Name: "test-system-role-negative",
OrganizationID: uuid.NullUUID{
UUID: orgID,
Valid: true,
},
SitePermissions: database.CustomRolePermissions{},
OrgPermissions: database.CustomRolePermissions{
{
Negate: true,
ResourceType: rbac.ResourceWorkspace.Type,
Action: policy.ActionShare,
},
},
UserPermissions: database.CustomRolePermissions{},
MemberPermissions: database.CustomRolePermissions{},
IsSystem: true,
})
require.NoError(t, err)
})
}
+102 -42
View File
@@ -1161,13 +1161,18 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID uuid.UUID, added, re
for _, roleName := range grantedRoles {
if _, isCustom := customRolesMap[roleName]; isCustom {
// To support a dynamic mapping of what roles can assign what, we need
// to store this in the database. For now, just use a static role so
// owners and org admins can assign roles.
if roleName.IsOrgRole() {
roleName = rbac.CustomOrganizationRole(roleName.OrganizationID)
} else {
roleName = rbac.CustomSiteRole()
// System roles are stored in the database but have a fixed, code-defined
// meaning. Do not rewrite the name for them so the static "who can assign
// what" mapping applies.
if !rbac.SystemRoleName(roleName.Name) {
// To support a dynamic mapping of what roles can assign what, we need
// to store this in the database. For now, just use a static role so
// owners and org admins can assign roles.
if roleName.IsOrgRole() {
roleName = rbac.CustomOrganizationRole(roleName.OrganizationID)
} else {
roleName = rbac.CustomSiteRole()
}
}
}
@@ -1282,33 +1287,39 @@ func (q *querier) customRoleEscalationCheck(ctx context.Context, actor rbac.Subj
// - Check custom roles are valid for their resource types + actions
// - Check the actor can create the custom role
// - Check the custom role does not grant perms the actor does not have
// - Prevent negative perms
// - Prevent roles with site and org permissions.
func (q *querier) customRoleCheck(ctx context.Context, role database.CustomRole) error {
// - Prevent negative perms for non-system roles
// - Prevent roles that have both organization scoped and non-organization scoped permissions
func (q *querier) customRoleCheck(ctx context.Context, role database.CustomRole, action policy.Action) error {
act, ok := ActorFromContext(ctx)
if !ok {
return ErrNoActor
}
// Org permissions require an org role
if role.OrganizationID.UUID == uuid.Nil && len(role.OrgPermissions) > 0 {
return xerrors.Errorf("organization permissions require specifying an organization id")
// Org and org member permissions require an org role.
if role.OrganizationID.UUID == uuid.Nil && (len(role.OrgPermissions) > 0 || len(role.MemberPermissions) > 0) {
return xerrors.Errorf("organization and member permissions require specifying an organization id")
}
// Org roles can only specify org permissions
// Org roles can only specify org permissions; system roles can also specify orgMember ones.
if role.OrganizationID.UUID != uuid.Nil && (len(role.SitePermissions) > 0 || len(role.UserPermissions) > 0) {
return xerrors.Errorf("organization roles specify site or user permissions")
}
// For now only system roles can specify orgMember permissions.
if !role.IsSystem && len(role.MemberPermissions) > 0 {
return xerrors.Errorf("non-system roles specify member permissions")
}
// The rbac.Role has a 'Valid()' function on it that will do a lot
// of checks.
rbacRole, err := rolestore.ConvertDBRole(database.CustomRole{
Name: role.Name,
DisplayName: role.DisplayName,
SitePermissions: role.SitePermissions,
OrgPermissions: role.OrgPermissions,
UserPermissions: role.UserPermissions,
OrganizationID: role.OrganizationID,
Name: role.Name,
DisplayName: role.DisplayName,
SitePermissions: role.SitePermissions,
OrgPermissions: role.OrgPermissions,
UserPermissions: role.UserPermissions,
MemberPermissions: role.MemberPermissions,
OrganizationID: role.OrganizationID,
})
if err != nil {
return xerrors.Errorf("invalid args: %w", err)
@@ -1333,6 +1344,16 @@ func (q *querier) customRoleCheck(ctx context.Context, role database.CustomRole)
return xerrors.Errorf("invalid custom role, cannot assign permissions to more than 1 org at a time")
}
// System roles are managed internally and may include permissions
// (including negative ones) that user-facing custom role APIs
// should reject. Still validate that the role shape and perms
// are internally consistent via rbacRole.Valid() above.
if role.IsSystem {
// Defensive programming: the caller should have checked that
// the action is authorized, but we double-check.
return q.authorizeContext(ctx, action, rbac.ResourceSystem)
}
// Prevent escalation
for _, sitePerm := range rbacRole.Site {
err := q.customRoleEscalationCheck(ctx, act, sitePerm, rbac.Object{Type: sitePerm.ResourceType})
@@ -4132,21 +4153,33 @@ func (q *querier) InsertCustomRole(ctx context.Context, arg database.InsertCusto
if !arg.OrganizationID.Valid || arg.OrganizationID.UUID == uuid.Nil {
return database.CustomRole{}, NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")}
}
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil {
rbacObj := rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)
if err := q.authorizeContext(ctx, policy.ActionCreate, rbacObj); err != nil {
return database.CustomRole{}, err
}
if arg.IsSystem {
err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem)
if err != nil {
return database.CustomRole{}, err
}
}
if err := q.customRoleCheck(ctx, database.CustomRole{
Name: arg.Name,
DisplayName: arg.DisplayName,
SitePermissions: arg.SitePermissions,
OrgPermissions: arg.OrgPermissions,
UserPermissions: arg.UserPermissions,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
OrganizationID: arg.OrganizationID,
ID: uuid.New(),
}); err != nil {
Name: arg.Name,
DisplayName: arg.DisplayName,
SitePermissions: arg.SitePermissions,
OrgPermissions: arg.OrgPermissions,
UserPermissions: arg.UserPermissions,
MemberPermissions: arg.MemberPermissions,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
OrganizationID: arg.OrganizationID,
ID: uuid.New(),
IsSystem: arg.IsSystem,
}, policy.ActionCreate); err != nil {
return database.CustomRole{}, err
}
return q.db.InsertCustomRole(ctx, arg)
@@ -4886,21 +4919,48 @@ func (q *querier) UpdateCustomRole(ctx context.Context, arg database.UpdateCusto
if !arg.OrganizationID.Valid || arg.OrganizationID.UUID == uuid.Nil {
return database.CustomRole{}, NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")}
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil {
rbacObj := rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbacObj); err != nil {
return database.CustomRole{}, err
}
existing, err := database.ExpectOne(q.db.CustomRoles(ctx, database.CustomRolesParams{
LookupRoles: []database.NameOrganizationPair{
{
Name: arg.Name,
OrganizationID: arg.OrganizationID.UUID,
},
},
ExcludeOrgRoles: false,
OrganizationID: uuid.Nil,
IncludeSystemRoles: true,
}))
if err != nil {
return database.CustomRole{}, err
}
if existing.IsSystem {
err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem)
if err != nil {
return database.CustomRole{}, err
}
}
if err := q.customRoleCheck(ctx, database.CustomRole{
Name: arg.Name,
DisplayName: arg.DisplayName,
SitePermissions: arg.SitePermissions,
OrgPermissions: arg.OrgPermissions,
UserPermissions: arg.UserPermissions,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
OrganizationID: arg.OrganizationID,
ID: uuid.New(),
}); err != nil {
Name: arg.Name,
DisplayName: arg.DisplayName,
SitePermissions: arg.SitePermissions,
OrgPermissions: arg.OrgPermissions,
UserPermissions: arg.UserPermissions,
MemberPermissions: arg.MemberPermissions,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
OrganizationID: arg.OrganizationID,
ID: uuid.New(),
IsSystem: existing.IsSystem,
}, policy.ActionUpdate); err != nil {
return database.CustomRole{}, err
}
return q.db.UpdateCustomRole(ctx, arg)
+7 -2
View File
@@ -15,6 +15,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/rolestore"
)
// nolint:tparallel
@@ -109,8 +110,12 @@ func TestGroupsAuth(t *testing.T) {
{
Name: "GroupMember",
Subject: rbac.Subject{
ID: users[0].ID.String(),
Roles: rbac.Roles(must(rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgMember(org.ID)}.Expand())),
ID: users[0].ID.String(),
Roles: must(rolestore.Expand(
context.Background(),
store,
[]rbac.RoleIdentifier{rbac.RoleMember(), rbac.ScopedRoleOrgMember(org.ID)},
)),
Groups: []string{
group.ID.String(),
},
+39
View File
@@ -105,12 +105,51 @@ func (s *MethodTestSuite) TearDownSuite() {
var testActorID = uuid.New()
type includeSystemRolesMatcher struct{}
func (includeSystemRolesMatcher) Matches(x any) bool {
p, ok := x.(database.CustomRolesParams)
if !ok {
return false
}
return p.IncludeSystemRoles
}
func (includeSystemRolesMatcher) String() string {
return "CustomRolesParams with IncludeSystemRoles=true"
}
// Mocked runs a subtest with a mocked database. Removing the overhead of a real
// postgres database resulting in much faster tests.
func (s *MethodTestSuite) Mocked(testCaseF func(dmb *dbmock.MockStore, faker *gofakeit.Faker, check *expects)) func() {
t := s.T()
mDB := dbmock.NewMockStore(gomock.NewController(t))
mDB.EXPECT().Wrappers().Return([]string{}).AnyTimes()
// dbauthz now expands DB-backed system roles (e.g. organization-member)
// during role-assignment validation, which triggers a CustomRoles lookup
// with IncludeSystemRoles=true.
mDB.EXPECT().CustomRoles(gomock.Any(), includeSystemRolesMatcher{}).DoAndReturn(func(_ context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) {
if len(arg.LookupRoles) == 0 {
return []database.CustomRole{}, nil
}
out := make([]database.CustomRole, 0, len(arg.LookupRoles))
for _, pair := range arg.LookupRoles {
// Minimal set of fields that the tested code uses.
out = append(out, database.CustomRole{
Name: pair.Name,
OrganizationID: uuid.NullUUID{
UUID: pair.OrganizationID,
Valid: pair.OrganizationID != uuid.Nil,
},
IsSystem: rbac.SystemRoleName(pair.Name),
ID: uuid.New(),
})
}
return out, nil
}).AnyTimes()
// Use a constant seed to prevent flakes from random data generation.
faker := gofakeit.New(0)