Naalya Handbook
RBAC & Scopes

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).

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

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

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

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

On this page