RBAC & Scopes
The CASL permission engine — two layers (action gate plus row filter) and why conditions are always derived, never stored.
A guard that answers "is this user allowed to call this endpoint?" is the easy half of authorization. The hard half is "which rows may they touch?" A campus receptionist can list students — but only the students at their campus. A guardian can read a report card — but only for the child they're actually linked to. The engine that answers both is CASL.
Two layers
Hold these two ideas separate in your head, because they come apart cleanly in the code:
- The guard decides whether the request gets through the door at all. It checks the
@RequirePermissionsmetadata on the handler against the user's ability — a coarse "can they do this action on this resource at all?" yes/no. - The scope decides which rows they see once they're through. Your service asks the same ability for a
WHEREclause and bolts it onto the query.
Both come from the same object: a CASL ability built fresh for every request. An ability is a list of rules shaped like "can read a student where campusId = X". The action and resource are the coarse part; the where (CASL calls them conditions) are the row-level part. The whole job of this permission code is to turn a user's roles into that list of rules, then read it back two ways.
Concretely, the same LIST student request travels through both layers: the guard reads the ability and asks "is there any rule that lets this user list students?" — and if so, lets the request through. The service then reads the same ability and asks "what WHERE clause do those rules imply?" — turning the campus condition into an actual SQL filter. One ability, two questions. The four pages below walk that journey: how the ability is built, how its rules get scoped, how both questions are asked, and what the frontend sees.
Conditions are derived, never stored
The table that links a role to its permissions (role_x_permission) stores only an (action, resource) pair. It does not store "campus = Lugazi". The campus condition is computed at request time from each role assignment's scopeLevel and campusId. Same role, two campus assignments, two different scoped abilities. This is the single most important idea in this section.
This section is the engine-room companion to Auth & Permissions, which covers how the guards are wired and how the JWT gets validated. Here we go inside the ability factory.
Actions & resources
Every permission is a pair: one Action, one Resource. Both are plain string enums in one shared file, and both are the literal currency of the whole system — decorators, the database, CASL rules, and the /auth/me payload all speak in these exact strings.
enum Action {
READ = 'read',
LIST = 'list',
CREATE = 'create',
UPDATE = 'update',
DELETE = 'delete',
MANAGE = 'manage', // implies all other actions
IMPERSONATE = 'impersonate',
}
enum Resource {
ALL = 'all', // wildcard subject
USER = 'user', STAFF = 'staff', STUDENT = 'student', GUARDIAN = 'guardian',
GRADE = 'grade', CLASS = 'class', APPLICATION = 'application',
CAMPUS = 'campus', DEPARTMENT = 'department', ROLE = 'role',
// ...curriculum, audit_log, cbt_exam, job_vacancy, etc.
}Two values carry special meaning the engine treats differently from the rest. Action.MANAGE is CASL's wildcard action — granting MANAGE on a resource means the user can do every action on it. Resource.ALL is the wildcard subject. Put them together as MANAGE + ALL and you have the super-grant: this user can do anything to anything. You'll see exactly where the factory short-circuits on that pair.
Adding a value is a wiring change, not a one-liner
A new Resource isn't useful until the registry knows it can be granted and the campus-scope map knows which column carries its campus. Adding the enum value is step one of several — there's a full walkthrough in Scoping Rules.
Where to go next
Building the Ability
From a JWT to a list of CASL rules: resolveContext, then buildAbility.
Scoping Rules
How abstract grants become campus-scoped row filters, and the map that drives it.
Enforcing Access
The guard, the service helpers, and how to gate plus scope an endpoint.
Auth/me & Impersonation
The contract the frontend consumes, plus platform_admin and impersonation.
User Lifecycle
The transaction that creates a user and its profile together, plus suspend, soft-delete cascade, seeding, and the sharp edges to watch for.
Building the Ability
How AbilityFactoryService turns a JWT into a list of CASL rules — resolveContext loads identity, buildAbility seeds the grants.