Naalya Handbook
RBAC & Scopes

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.

apps/server/src/app/auth/ability/ability-factory.service.ts
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:

  1. MANAGE + ALL — the assignment grants everything. The function registers it and returns immediately; nothing else can narrow it. This is how a school's Super Admin role works.
  2. School-scoped assignment — the permission applies across the whole tenant, so no campus condition is added. The grant goes on as-is.
  3. Campus-scoped assignment — the permission must be restricted to the assignment's campus, so it's handed to applyCampusScopedRule along with the campusId.

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.

libs/shared/src/permission/permission-scope/campus-scope.ts
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:

apps/server/src/app/auth/ability/ability-factory.service.ts
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 entryExampleRule produced for a campus-scoped grant
Not in the mapUSER, ROLE, SUBJECTUnconditioned — full access if they have the permission at all
Required scalarSTUDENT.campusId{ campusId: <yourCampus> }
Nullable scalarDEPARTMENT.campusId{ campusId: <yourCampus> }, plus { campusId: null } for READ/LIST
Array fieldGUARDIAN.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.

libs/shared/src/permission/role-conditions/compile-role-conditions.ts
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.

libs/shared/src/permission/role-conditions/filterable-fields.ts
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 only campusId collapse to { campusId: { $in: [a, b] } }.
  • Otherwise, keep them separate. Different fields or non-$in operators stay as distinct duplicate rules — CASL evaluates duplicates as OR, which the matcher does support (it just can't do an explicit $or key).

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.

  1. Add the value to Resource in libs/shared/src/permission/permission.enum.ts.
  2. Register it in the permission registry with a kind (full or readonly) and label, so roles are allowed to grant it. Roles & Permissions Admin covers the registry.
  3. If the resource has a campus column, add an entry to CAMPUS_SCOPE_FIELD in campus-scope.ts so campus-scoped grants restrict correctly.
  4. If non-staff identities should have static access, add the can(...) calls to the relevant branch of buildAbility.
  5. Use it: @RequirePermissions({ action, resource }) on the controller, getScope/assertCanAccess in 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

On this page