Files
coder/coderd/database/dbauthz/customroles_test.go
T
George K cc2efe9e1f 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)
2026-01-12 18:19:19 -08:00

482 lines
15 KiB
Go

package dbauthz_test
import (
"testing"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
// TestInsertCustomRoles verifies creating custom roles cannot escalate permissions.
func TestInsertCustomRoles(t *testing.T) {
t.Parallel()
userID := uuid.New()
subjectFromRoles := func(roles rbac.ExpandableRoles) rbac.Subject {
return rbac.Subject{
FriendlyName: "Test user",
ID: userID.String(),
Roles: roles,
Groups: nil,
Scope: rbac.ScopeAll,
}
}
canCreateCustomRole := rbac.Role{
Identifier: rbac.RoleIdentifier{Name: "can-assign"},
DisplayName: "",
Site: rbac.Permissions(map[string][]policy.Action{
rbac.ResourceAssignRole.Type: {policy.ActionRead},
rbac.ResourceAssignOrgRole.Type: {policy.ActionRead, policy.ActionCreate},
}),
}
merge := func(u ...interface{}) rbac.Roles {
all := make([]rbac.Role, 0)
for _, v := range u {
switch t := v.(type) {
case rbac.Role:
all = append(all, t)
case rbac.ExpandableRoles:
all = append(all, must(t.Expand())...)
case rbac.RoleIdentifier:
all = append(all, must(rbac.RoleByName(t)))
default:
panic("unknown type")
}
}
return all
}
orgID := uuid.New()
testCases := []struct {
name string
subject rbac.ExpandableRoles
// Perms to create on new custom role
organizationID uuid.UUID
site []codersdk.Permission
org []codersdk.Permission
user []codersdk.Permission
member []codersdk.Permission
errorContains string
}{
{
// No roles, so no assign role
name: "no-roles",
organizationID: orgID,
subject: rbac.RoleIdentifiers{},
errorContains: "forbidden",
},
{
// This works because the new role has 0 perms
name: "empty",
organizationID: orgID,
subject: merge(canCreateCustomRole),
},
{
name: "mixed-scopes",
organizationID: orgID,
subject: merge(canCreateCustomRole, rbac.RoleOwner()),
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}),
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}),
errorContains: "organization roles specify site or user permissions",
},
{
name: "invalid-action",
organizationID: orgID,
subject: merge(canCreateCustomRole, rbac.RoleOwner()),
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
// Action does not go with resource
codersdk.ResourceWorkspace: {codersdk.ActionViewInsights},
}),
errorContains: "invalid action",
},
{
name: "invalid-resource",
organizationID: orgID,
subject: merge(canCreateCustomRole, rbac.RoleOwner()),
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
"foobar": {codersdk.ActionViewInsights},
}),
errorContains: "invalid resource",
},
{
// Not allowing these at this time.
name: "negative-permission",
organizationID: orgID,
subject: merge(canCreateCustomRole, rbac.RoleOwner()),
org: []codersdk.Permission{
{
Negate: true,
ResourceType: codersdk.ResourceWorkspace,
Action: codersdk.ActionRead,
},
},
errorContains: "no negative permissions",
},
{
name: "wildcard", // not allowed
organizationID: orgID,
subject: merge(canCreateCustomRole, rbac.RoleOwner()),
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {"*"},
}),
errorContains: "no wildcard symbols",
},
// escalation checks
{
name: "read-workspace-escalation",
organizationID: orgID,
subject: merge(canCreateCustomRole),
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}),
errorContains: "not allowed to grant this permission",
},
{
name: "read-workspace-outside-org",
organizationID: uuid.New(),
subject: merge(canCreateCustomRole, rbac.ScopedRoleOrgAdmin(orgID)),
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}),
errorContains: "not allowed to grant this permission",
},
{
name: "user-escalation",
// These roles do not grant user perms
organizationID: orgID,
subject: merge(canCreateCustomRole, rbac.ScopedRoleOrgAdmin(orgID)),
user: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}),
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,
subject: merge(canCreateCustomRole, rbac.RoleTemplateAdmin()),
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceDeploymentConfig: {codersdk.ActionUpdate}, // not ok!
}),
errorContains: "organization roles specify site or user permissions",
},
// ok!
{
name: "read-workspace-template-admin",
organizationID: orgID,
subject: merge(canCreateCustomRole, rbac.RoleTemplateAdmin()),
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}),
},
{
name: "read-workspace-in-org",
organizationID: orgID,
subject: merge(canCreateCustomRole, rbac.ScopedRoleOrgAdmin(orgID)),
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
rec := &coderdtest.RecordingAuthorizer{
Wrapped: rbac.NewAuthorizer(prometheus.NewRegistry()),
}
az := dbauthz.New(db, rec, slog.Make(), coderdtest.AccessControlStorePointer())
subject := subjectFromRoles(tc.subject)
ctx := testutil.Context(t, testutil.WaitMedium)
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),
MemberPermissions: db2sdk.List(tc.member, convertSDKPerm),
})
if tc.errorContains != "" {
require.ErrorContains(t, err, tc.errorContains)
} else {
require.NoError(t, err)
// Verify the role is fetched with the lookup filter.
roles, err := az.CustomRoles(ctx, database.CustomRolesParams{
LookupRoles: []database.NameOrganizationPair{
{
Name: "test-role",
OrganizationID: tc.organizationID,
},
},
ExcludeOrgRoles: false,
OrganizationID: uuid.Nil,
})
require.NoError(t, err)
require.Len(t, roles, 1)
}
})
}
}
func convertSDKPerm(perm codersdk.Permission) database.CustomRolePermission {
return database.CustomRolePermission{
Negate: perm.Negate,
ResourceType: string(perm.ResourceType),
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)
})
}