Scoping Rules
How abstract staff grants become campus-scoped row filters — applyStaffAbilities, the campus-scope map, stored conditions, and rule merging.
Scoping is the half of RBAC that decides which rows a staff member sees. This page follows a single permission from a role assignment all the way to a CASL condition, then shows how to extend the system safely.
applyStaffAbilities
This is the function that turns abstract grants into scoped rules, and it's worth reading slowly because it encodes the project's whole scoping policy. For each role assignment, for each (action, resource) permission on it, three things can happen.
for (const assignment of assignments) {
for (const perm of assignment.permissions) {
if (perm.action === Action.MANAGE && perm.resource === Resource.ALL) {
can(Action.MANAGE, Resource.ALL);
return; // super-grant: stop, you can do anything
}
if (assignment.scopeLevel === 'school') {
can(perm.action, perm.resource, perm.conditions); // no campus condition
} else if (assignment.scopeLevel === 'campus' && assignment.campusId) {
this.applyCampusScopedRule(can, perm.action, perm.resource,
assignment.campusId, perm.conditions);
}
}
}Read that as a decision tree:
MANAGE + ALL— the assignment grants everything. The function registers it andreturns immediately; nothing else can narrow it. This is how a school's Super Admin role works.- School-scoped assignment — the permission applies across the whole tenant, so no campus condition is added. The grant goes on as-is.
- Campus-scoped assignment — the permission must be restricted to the assignment's campus, so it's handed to
applyCampusScopedRulealong with thecampusId.
A staff member's JWT does not carry their campus. If someone has a campus-A assignment and a campus-B assignment, this loop runs twice and produces OR-ed rules for both campuses — regardless of what's (not) in the token.
Campus scoping
"Restrict to campus" means different things for different resources. A student has a non-null campusId. A department can belong to a campus or to the whole school (nullable). A guardian belongs to an array of campuses. And a user has no campus column at all. The factory can't guess this — it looks it up in a map.
export const CAMPUS_SCOPE_FIELD: ReadonlyMap<Resource, CampusScopeField> = new Map([
[Resource.STUDENT, 'campusId'], // required scalar
[Resource.DEPARTMENT, { field: 'campusId', nullable: true }], // nullable scalar
[Resource.GUARDIAN, { field: 'campusIds', isArray: true }], // array column
[Resource.CAMPUS, 'id'], // campus IS the row
// ...class, application, staff, academic_report, job_vacancy, cbt_exam
]);applyCampusScopedRule reads that map and behaves accordingly:
const mapping = CAMPUS_SCOPE_FIELD.get(resource);
if (!mapping) {
can(action, resource, extraConditions); // no campus column → unconditioned
return;
}
const field = typeof mapping === 'string' ? mapping : mapping.field;
const isNullable = typeof mapping === 'object' && 'nullable' in mapping && mapping.nullable;
const isReadAction = action === Action.READ || action === Action.LIST;
can(action, resource, { [field]: campusId, ...extraConditions }); // your campus
if (isNullable && isReadAction) {
can(action, resource, { [field]: null, ...extraConditions }); // + school-level rows
}The behavior, summarized:
| Map entry | Example | Rule produced for a campus-scoped grant |
|---|---|---|
| Not in the map | USER, ROLE, SUBJECT | Unconditioned — full access if they have the permission at all |
| Required scalar | STUDENT.campusId | { campusId: <yourCampus> } |
| Nullable scalar | DEPARTMENT.campusId | { campusId: <yourCampus> }, plus { campusId: null } for READ/LIST |
| Array field | GUARDIAN.campusIds | { campusIds: <yourCampus> } |
That nullable-plus-null pair is a deliberate policy: campus staff can read school-level items (a department that belongs to no campus) but can only write to their own campus, because the extra campusId: null rule is added for READ/LIST only.
Conditions are never assigned to users directly
Notice you never write { campusId: X } anywhere outside this factory. An admin assigns a role at a campus scope; the condition is the factory's job. There is no UI and no table for "give this user a campusId condition" — it falls out of the assignment automatically. Departments work the same way via the parallel departmentAssignments loop.
Role conditions
There is one narrow channel where an admin can add a custom condition, and it's deliberately fenced. A role's permission can carry stored "filter" conditions — e.g. "this Grade Teacher role only sees students in the Science department." Those are stored as triples and compiled into CASL conditions at request time.
for (const triple of triples) {
conditions[triple.field] =
triple.operator === 'in' ? { $in: triple.value } : triple.value;
}The security boundary is FILTERABLE_FIELDS — a code-defined allow-list of which fields a role may filter on, per resource. An admin can't filter on an arbitrary column; the field has to be on the list, and the operator is limited to eq/in because the converter only handles those two safely.
export const FILTERABLE_FIELDS: ReadonlyMap<Resource, readonly FieldSpec[]> = new Map([
[Resource.STUDENT, [{ field: 'department', operators: ['eq', 'in'], valueType: 'string' }]],
]);These stored conditions ride into applyStaffAbilities as perm.conditions and get merged with the campus condition ({ [field]: campusId, ...extraConditions }), so a campus-scoped Science teacher ends up with both filters AND-ed together.
Merging rules
After all the can(...) calls, the rule list often has duplicates and overlaps — two campus assignments both granting read student, say. mergeCaslRules collapses them. This isn't cosmetic: the default Mongo matcher behind createMongoAbility cannot evaluate a top-level $or, so the merger has to combine rules without producing one.
It groups rules by (inverted, action, subject, fields) and, per group, applies a few rules in priority order:
- A conditionless rule wins. If any rule in the group has no conditions, the whole group collapses to that one — broader access beats narrower.
- Same-field values merge into
$in. Two rules that both constrain onlycampusIdcollapse to{ campusId: { $in: [a, b] } }. - Otherwise, keep them separate. Different fields or non-
$inoperators stay as distinct duplicate rules — CASL evaluates duplicates as OR, which the matcher does support (it just can't do an explicit$orkey).
You rarely call this directly, but knowing it exists explains why your /auth/me rules look tidier than the raw can() calls would suggest.
Adding a resource
Adding to the enums is a rare, deliberate change, and a new Resource ripples outward. The one step you must not skip is the campus-scope map.
- Add the value to
Resourceinlibs/shared/src/permission/permission.enum.ts. - Register it in the permission registry with a
kind(fullorreadonly) and label, so roles are allowed to grant it. Roles & Permissions Admin covers the registry. - If the resource has a campus column, add an entry to
CAMPUS_SCOPE_FIELDincampus-scope.tsso campus-scoped grants restrict correctly. - If non-staff identities should have static access, add the
can(...)calls to the relevant branch ofbuildAbility. - Use it:
@RequirePermissions({ action, resource })on the controller,getScope/assertCanAccessin the service.
A campus-scoped resource with no scope entry leaks
This is the trap in step 3. If a resource has a campusId column but you forget to add it to CAMPUS_SCOPE_FIELD, applyCampusScopedRule falls into its "not in the map → grant unconditioned" branch. A campus-scoped staffer then sees every campus's rows. The map is the safety boundary — keep it in sync with your entities.
Where to go next
Building the Ability
Where applyStaffAbilities is called from: resolveContext and buildAbility.
Enforcing Access
How these scoped rules get read back into a WHERE clause at the service layer.
Add a Resource Module
Build a resource end to end, including picking the right @RequirePermissions pair.
Roles & Permissions Admin
The registry, role CRUD, and how assignments are managed and scoped.