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
+50 -33
View File
@@ -168,7 +168,7 @@ func TestFilter(t *testing.T) {
Name: "Admin",
Actor: Subject{
ID: userIDs[0].String(),
Roles: RoleIdentifiers{ScopedRoleOrgMember(orgIDs[0]), RoleAuditor(), RoleOwner(), RoleMember()},
Roles: RoleIdentifiers{RoleAuditor(), RoleOwner(), RoleMember()},
},
ObjectType: ResourceWorkspace.Type,
Action: policy.ActionRead,
@@ -177,7 +177,7 @@ func TestFilter(t *testing.T) {
Name: "OrgAdmin",
Actor: Subject{
ID: userIDs[0].String(),
Roles: RoleIdentifiers{ScopedRoleOrgMember(orgIDs[0]), ScopedRoleOrgAdmin(orgIDs[0]), RoleMember()},
Roles: RoleIdentifiers{ScopedRoleOrgAdmin(orgIDs[0]), RoleMember()},
},
ObjectType: ResourceWorkspace.Type,
Action: policy.ActionRead,
@@ -186,7 +186,7 @@ func TestFilter(t *testing.T) {
Name: "OrgMember",
Actor: Subject{
ID: userIDs[0].String(),
Roles: RoleIdentifiers{ScopedRoleOrgMember(orgIDs[0]), ScopedRoleOrgMember(orgIDs[1]), RoleMember()},
Roles: RoleIdentifiers{RoleMember()},
},
ObjectType: ResourceWorkspace.Type,
Action: policy.ActionRead,
@@ -196,11 +196,9 @@ func TestFilter(t *testing.T) {
Actor: Subject{
ID: userIDs[0].String(),
Roles: RoleIdentifiers{
ScopedRoleOrgMember(orgIDs[0]), ScopedRoleOrgAdmin(orgIDs[0]),
ScopedRoleOrgMember(orgIDs[1]), ScopedRoleOrgAdmin(orgIDs[1]),
ScopedRoleOrgMember(orgIDs[2]), ScopedRoleOrgAdmin(orgIDs[2]),
ScopedRoleOrgMember(orgIDs[4]),
ScopedRoleOrgMember(orgIDs[5]),
ScopedRoleOrgAdmin(orgIDs[0]),
ScopedRoleOrgAdmin(orgIDs[1]),
ScopedRoleOrgAdmin(orgIDs[2]),
RoleMember(),
},
},
@@ -221,10 +219,6 @@ func TestFilter(t *testing.T) {
Actor: Subject{
ID: userIDs[0].String(),
Roles: RoleIdentifiers{
ScopedRoleOrgMember(orgIDs[0]),
ScopedRoleOrgMember(orgIDs[1]),
ScopedRoleOrgMember(orgIDs[2]),
ScopedRoleOrgMember(orgIDs[3]),
RoleMember(),
},
},
@@ -235,7 +229,7 @@ func TestFilter(t *testing.T) {
Name: "ScopeApplicationConnect",
Actor: Subject{
ID: userIDs[0].String(),
Roles: RoleIdentifiers{ScopedRoleOrgMember(orgIDs[0]), RoleAuditor(), RoleOwner(), RoleMember()},
Roles: RoleIdentifiers{RoleAuditor(), RoleOwner(), RoleMember()},
},
ObjectType: ResourceWorkspace.Type,
Action: policy.ActionRead,
@@ -312,7 +306,7 @@ func TestAuthorizeDomain(t *testing.T) {
Groups: []string{allUsersGroup},
Roles: Roles{
must(RoleByName(RoleMember())),
must(RoleByName(ScopedRoleOrgMember(defOrg))),
orgMemberRole(defOrg),
},
}
@@ -456,7 +450,7 @@ func TestAuthorizeDomain(t *testing.T) {
Scope: must(ExpandScope(ScopeAll)),
Roles: Roles{
must(RoleByName(ScopedRoleOrgAdmin(defOrg))),
must(RoleByName(ScopedRoleOrgMember(defOrg))),
orgMemberRole(defOrg),
must(RoleByName(RoleMember())),
},
}
@@ -502,39 +496,40 @@ func TestAuthorizeDomain(t *testing.T) {
},
}
siteAdminWorkspaceActions := slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionShare)
testAuthorize(t, "SiteAdmin", user, []authTestCase{
// Similar to an orphaned user, but has site level perms
{resource: ResourceTemplate.AnyOrganization(), actions: []policy.Action{policy.ActionCreate}, allow: true},
// Org + me
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), actions: ResourceWorkspace.AvailableActions(), allow: true},
{resource: ResourceWorkspace.InOrg(defOrg), actions: ResourceWorkspace.AvailableActions(), allow: true},
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), actions: siteAdminWorkspaceActions, allow: true},
{resource: ResourceWorkspace.InOrg(defOrg), actions: siteAdminWorkspaceActions, allow: true},
{resource: ResourceWorkspace.WithOwner(user.ID), actions: ResourceWorkspace.AvailableActions(), allow: true},
{resource: ResourceWorkspace.WithOwner(user.ID), actions: siteAdminWorkspaceActions, allow: true},
{resource: ResourceWorkspace.All(), actions: ResourceWorkspace.AvailableActions(), allow: true},
{resource: ResourceWorkspace.All(), actions: siteAdminWorkspaceActions, allow: true},
// Other org + me
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.ID), actions: ResourceWorkspace.AvailableActions(), allow: true},
{resource: ResourceWorkspace.InOrg(unusedID), actions: ResourceWorkspace.AvailableActions(), allow: true},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.ID), actions: siteAdminWorkspaceActions, allow: true},
{resource: ResourceWorkspace.InOrg(unusedID), actions: siteAdminWorkspaceActions, allow: true},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: ResourceWorkspace.AvailableActions(), allow: true},
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: siteAdminWorkspaceActions, allow: true},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: ResourceWorkspace.AvailableActions(), allow: true},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: siteAdminWorkspaceActions, allow: true},
// Other org + other use
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me"), actions: ResourceWorkspace.AvailableActions(), allow: true},
{resource: ResourceWorkspace.InOrg(unusedID), actions: ResourceWorkspace.AvailableActions(), allow: true},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me"), actions: siteAdminWorkspaceActions, allow: true},
{resource: ResourceWorkspace.InOrg(unusedID), actions: siteAdminWorkspaceActions, allow: true},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: ResourceWorkspace.AvailableActions(), allow: true},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: siteAdminWorkspaceActions, allow: true},
})
user = Subject{
ID: "me",
Scope: must(ExpandScope(ScopeApplicationConnect)),
Roles: Roles{
must(RoleByName(ScopedRoleOrgMember(defOrg))),
orgMemberRole(defOrg),
must(RoleByName(RoleMember())),
},
}
@@ -762,7 +757,7 @@ func TestAuthorizeLevels(t *testing.T) {
testAuthorize(t, "AdminAlwaysAllow", user,
cases(func(c authTestCase) authTestCase {
c.actions = ResourceWorkspace.AvailableActions()
c.actions = slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionShare)
c.allow = true
return c
}, []authTestCase{
@@ -890,7 +885,7 @@ func TestAuthorizeScope(t *testing.T) {
ID: "me",
Roles: Roles{
must(RoleByName(RoleMember())),
must(RoleByName(ScopedRoleOrgMember(defOrg))),
orgMemberRole(defOrg),
},
Scope: must(ExpandScope(ScopeApplicationConnect)),
}
@@ -926,7 +921,7 @@ func TestAuthorizeScope(t *testing.T) {
ID: "me",
Roles: Roles{
must(RoleByName(RoleMember())),
must(RoleByName(ScopedRoleOrgMember(defOrg))),
orgMemberRole(defOrg),
},
Scope: Scope{
Role: Role{
@@ -1015,7 +1010,7 @@ func TestAuthorizeScope(t *testing.T) {
ID: "me",
Roles: Roles{
must(RoleByName(RoleMember())),
must(RoleByName(ScopedRoleOrgMember(defOrg))),
orgMemberRole(defOrg),
},
Scope: Scope{
Role: Role{
@@ -1070,7 +1065,7 @@ func TestAuthorizeScope(t *testing.T) {
ID: meID.String(),
Roles: Roles{
must(RoleByName(RoleMember())),
must(RoleByName(ScopedRoleOrgMember(defOrg))),
orgMemberRole(defOrg),
},
Scope: must(ScopeNoUserData.Expand()),
}
@@ -1138,7 +1133,7 @@ func TestAuthorizeScope(t *testing.T) {
// This is odd behavior, as without this membership role, the test for
// the workspace fails. Maybe scopes should just assume the user
// is a member.
must(RoleByName(ScopedRoleOrgMember(defOrg))),
orgMemberRole(defOrg),
},
Scope: Scope{
Role: Role{
@@ -1404,6 +1399,28 @@ func testAuthorize(t *testing.T, name string, subject Subject, sets ...[]authTes
}
}
// orgMemberRole returns an organization-member role for RBAC-only tests.
//
// organization-member is now a DB-backed system role (not a built-in role), so
// RoleByName won't resolve it here. Assume the default behavior: workspace
// sharing enabled.
func orgMemberRole(orgID uuid.UUID) Role {
workspaceSharingDisabled := false
orgPerms, memberPerms := OrgMemberPermissions(workspaceSharingDisabled)
return Role{
Identifier: ScopedRoleOrgMember(orgID),
DisplayName: "",
Site: []Permission{},
User: []Permission{},
ByOrgID: map[string]OrgPermissions{
orgID.String(): {
Org: orgPerms,
Member: memberPerms,
},
},
}
}
func must[T any](value T, err error) T {
if err != nil {
panic(err)