Building the Ability
How AbilityFactoryService turns a JWT into a list of CASL rules — resolveContext loads identity, buildAbility seeds the grants.
The heart of the engine is AbilityFactoryService. Its job is to turn a user's identity into a list of CASL rules, and it does it in two clean steps you should keep separate in your head.
createForUser
The public entry point, createForUser(jwt), does two things in sequence: resolve context (load everything the user's identity needs from the database) then build the ability (turn that context into CASL rules).
async createForUser(jwt: JwtPayload): Promise<AppAbility> {
const ctx = await this.resolveContext(jwt);
return this.buildAbility(ctx);
}resolveContext
resolveContext branches on the user's type, because different kinds of user derive their powers from completely different places. Staff get their power from roles in the database; everyone else gets it from who they are.
switch (jwt.type) {
case UserType.STAFF:
base.roleAssignments = await this.getStaffRoleAssignments(jwt.sub);
base.departmentAssignments = await this.getStaffDepartmentAssignments(jwt.sub);
// ...look up staffProfileId
break;
case UserType.GUARDIAN:
// ...guardianProfileId
base.linkedStudentIds = await this.getLinkedStudentIds(jwt.sub); // verified only
break;
case UserType.STUDENT: /* studentProfileId + campusId from profile */ break;
case UserType.GUEST: /* guestProfileId */ break;
case UserType.PLATFORM_ADMIN:
// No DB lookups — powers are hardcoded in buildAbility.
break;
}The staff branch is the only one that touches role tables. getStaffRoleAssignments pulls every user_x_role row with its role and that role's permissions, and crucially keeps each assignment's scopeLevel and campusId — the raw material for scoping later.
For guardians, getLinkedStudentIds is where a subtle but vital filter lives: it returns only links where isVerified: true. An unverified guardian-student link grants nothing.
buildAbility
buildAbility opens a CASL AbilityBuilder and, again branching on type, calls can(action, resource, conditions?) to register rules. Non-staff users get a fixed, hardcoded set of self-service grants — their abilities are entirely derived from identity and cannot be changed by assigning roles.
A student, for example, can read their own user record, their own profile, and their own grades — every rule carries a condition pinning it to an id that came out of resolveContext:
case UserType.STUDENT:
can(Action.READ, Resource.USER, { id: ctx.userId });
if (ctx.studentProfileId) {
can(Action.READ, Resource.STUDENT, { id: ctx.studentProfileId });
can(Action.READ, [Resource.GRADE, Resource.ACADEMIC_REPORT], {
studentId: ctx.studentProfileId,
});
}
if (ctx.campusId) can(Action.READ, Resource.CAMPUS, { id: ctx.campusId });
break;The guardian branch is the same shape, but reads across multiple children using CASL's $in operator: { studentId: { $in: ctx.linkedStudentIds } }. That $in is why the verified-only filter matters — an empty array would produce a rule that matches nothing.
Staff are different. They get a small base of self-service grants (read own user/profile, LIST staff, campus, department, role) and then everything real is unioned on top from their roles:
case UserType.STAFF:
can(Action.READ, Resource.USER, { id: ctx.userId });
if (ctx.staffProfileId) {
can(Action.READ, Resource.STAFF, { id: ctx.staffProfileId });
can(Action.LIST, Resource.CAMPUS);
can(Action.LIST, Resource.ROLE);
// ...other base LIST grants
}
this.applyStaffAbilities(ctx.roleAssignments, ctx.departmentAssignments, can);
break;Finally buildAbility calls build(), runs the rule list through mergeCaslRules, and returns a fresh createMongoAbility. That returned object is the AppAbility — the single source of truth for the rest of the request.
The staff branch is where role-driven power lives
For students and guardians, buildAbility is the whole story — read the branch and you know exactly what they can do. For staff, the interesting part happens inside applyStaffAbilities, which is where assignments become campus-scoped rules.
Where to go next
Scoping Rules
applyStaffAbilities, the campus-scope map, stored role conditions, and rule merging.
RBAC & Scopes
Back to the overview: the two-layer model and the Action/Resource enums.
Users & Profiles
The five user types whose static abilities the factory branches on.
Enforcing Access
How the ability gets read back: the guard and the service-layer helpers.