Roles & Permissions Admin
The management side of authorization — the permission registry, plus how roles are created, cloned, edited, and assigned to users.
There are two halves to authorization on this backend. One half enforces permissions at request time — guards, the CASL ability factory, turning "only your campus" into SQL. The other half — this section — is the management side: what permissions even exist, how a school's roles get created, how an admin edits a role, and how a role lands on a user. If you've read RBAC & Scopes you already know how a permission is evaluated; here you'll learn how one gets into the system in the first place.
The chain
The mental model is a short chain. A permission is just an (action, resource) pair — READ STUDENT, MANAGE ROLE. A role is a named bundle of those pairs. A role assignment glues a role onto a specific user, optionally narrowed to one campus. Nobody is ever granted a raw permission directly; you always go through a role, and you always assign the role to a user. That indirection is the whole design, and it's what makes the rest of this section click.
permission registry → role (bundle of permissions) → assignment (role + user + scope)
(what's assignable) POST /roles, PUT /roles/:id/permissions POST /users/:id/rolesThis is the admin side — enforcement lives elsewhere
This section covers managing roles. How a stored permission becomes an allow/deny decision (the guard pipeline, CASL abilities, scope-to-SQL) is RBAC & Scopes. Read that first if "ability factory" or "condition" is new to you — these pages lean on those ideas without re-explaining them.
The permission registry
Not every (action, resource) combination is a real permission. You can't grant DELETE AUDIT_LOG because audit logs are read-only. The set of valid permissions is defined declaratively, in code, in libs/shared/src/permission/permission-registry/. Each resource is registered with a kind that expands to a fixed list of actions, so you describe a resource once instead of spelling out every action by hand.
Two kinds exist, and each maps to a set of actions:
const KIND_ACTIONS: Record<ResourceKind, Action[]> = {
full: [Action.READ, Action.LIST, Action.CREATE, Action.UPDATE, Action.DELETE, Action.MANAGE],
readonly: [Action.READ, Action.LIST],
};
interface ResourceConfig { resource: Resource; kind: ResourceKind; label: string; actions?: Action[] }So registering a resource as full mints six permissions for it; readonly mints two. A single resource config is tiny — Role is a full resource, AuditLog is readonly:
export const rolePermissions: ResourceConfig = {
resource: Resource.ROLE,
kind: 'full',
label: 'Role',
};The configs are split across themed files — user, academic, administration, operations, observability — and index.ts gathers them into one RESOURCE_CONFIGS object keyed by Resource. Here's the lay of the land:
| Group | A few of the resources |
|---|---|
| User | USER, STAFF, STUDENT, GUARDIAN (full); SCHOOL_DASHBOARD (readonly) |
| Academic | GRADE, CLASS, ACADEMIC_REPORT, ACADEMIC_YEAR, SUBJECT, ENROLLMENT, CURRICULUM, CBT_EXAM, GRADING_CONFIG, SCHEME_OF_WORK, LESSON_PLAN, TEMPLATE (full) |
| Administration | ROLE, CAMPUS, DEPARTMENT, APPLICATION, SCHOOL (full) |
| Bursary | TRANSACTION, PAYMENT_GATEWAY, PAYMENT_ITEM (full) |
| Operations | JOB_VACANCY, KNOWLEDGE_BASE, API_KEY, ENQUIRY, NOTICE_BOARD, FEEDBACK, SOCIAL_CONNECTION (full) |
| Observability | AUDIT_LOG, ANALYTICS (readonly) |
The enum holds 35 resources today. Adding one is a three-step rule: the Resource enum, a ResourceConfig in the right themed file, and a grant in the relevant SYSTEM_ROLES seed arrays (SUPER_ADMIN's MANAGE ALL covers it implicitly).
Two shapes
At module load, permission.registry.ts walks RESOURCE_CONFIGS and flattens everything into two shapes you'll see used everywhere. PERMISSION_REGISTRY is a flat array of every valid permission, each carrying an auto-generated description and tags (good for search). PERMISSION_REGISTRY_MAP is the same data keyed resource → action → definition (good for grouping in a UI).
The flat list is also the single source of truth for validation. The helper isValidPermission is what every role-write goes through, and it encodes one special case:
function isValidPermission(action: Action, resource: Resource): boolean {
if (action === Action.MANAGE && resource === Resource.ALL) return true; // super-admin grant
return PERMISSION_REGISTRY.some(
(permission) => permission.action === action && permission.resource === resource,
);
}MANAGE ALL is always valid — that's the wildcard the Super Admin role carries. Everything else must physically exist in the registry. A readonly resource simply has no CREATE/UPDATE/DELETE rows, so those pairs are rejected without any special-casing.
The registry is the allow-list — invented pairs get a 400
When a client posts a permission for a role, isValidPermission runs over every pair. If even one isn't in PERMISSION_REGISTRY (and isn't MANAGE ALL), the whole request fails with a BadRequestException. You can't sneak a typo'd or fictional permission into the database — adding a new assignable permission means adding a ResourceConfig, not posting clever JSON.
The registry endpoint
The frontend's role editor needs to show admins the menu of assignable permissions. That's served by a single read-only endpoint on the auth controller, which just hands back both shapes:
@Get('permissions/registry')
@ApiOperation({ operationId: 'getPermissionsRegistry', summary: 'Get permissions' /* ... */ })
getPermissionsRegistry(): PermissionsRegistryResponseDto {
return {
permissions: PERMISSION_REGISTRY, // flat list, for search
map: PERMISSION_REGISTRY_MAP, // keyed by resource → action, for grouping
};
}GET /api/v1/auth/permissions/registry (operationId getPermissionsRegistry) requires only authentication — any logged-in user can fetch the catalog. The flat permissions array drives a searchable list; the map drives a grouped-by-resource layout. Nothing here touches the database; the registry is built once in memory from the code.
Where to go next
The rest of this section follows the chain from a fresh school's cloned roles to a scoped assignment on a user.
System-Role Cloning
Where a school's per-school roles come from: the template, cloneSystemRolesForSchool, and frozen names.
Managing Roles
Role CRUD, the MANAGE ROLE guard, and replace-all permission editing.
Assigning Roles
Attaching a role to a user with a campus scope, automatic assignment, and the gotchas.
RBAC & Scopes
The enforcement half: guards, the CASL ability factory, and turning scope into SQL.
Auth/me & Impersonation
The /auth/me contract the frontend consumes — permissions vs rules vs scope — plus platform_admin's static abilities and impersonation.
System-Role Cloning
Roles are per-school copies cloned from a template — how cloneSystemRolesForSchool works, where it fires, and why system-role names are frozen.