Naalya Handbook
RBAC & Scopes

Auth/me & Impersonation

The /auth/me contract the frontend consumes — permissions vs rules vs scope — plus platform_admin's static abilities and impersonation.

The ability is built on the server, but the frontend needs a copy to decide what to render. GET /api/v1/auth/me is that bridge, and impersonation is the special path where one user borrows another's ability entirely.

/auth/me

GET /api/v1/auth/me is where the frontend learns who the user is and what they can do. The handler builds the ability and returns it in two forms plus a scope summary, because the frontend needs both coarse and fine-grained checks.

apps/server/src/app/auth/auth.service.ts
const ability = this.abilityFactory.buildAbility(ctx);
const rules = ability.rules;
// ...derive flat, de-duped (action, resource) `permissions` from rules, conditions stripped
return { user, scope, roles, permissions, rules };

The three authorization fields each answer a different question on the client:

FieldShapeUsed for
permissionsFlat, de-duped (action, resource) list, conditions strippedCoarse UI checks — "show the Students tab?"
rulesRaw CASL rules including conditionsReconstruct the exact ability with createMongoAbility(rules) for record-level checks — "can they edit this student?"
scope{ level, campuses, homeCampus? }Campus pickers, "you are viewing campus X" banners

level is 'school' if the user has any school-scoped role (or no roles at all), otherwise 'campus'. homeCampus comes from the staff profile and is independent of role scope — it's where they sit, not what they can reach.

Conditions must round-trip cleanly to the client

The rules are serialized as plain objects so the frontend's CASL can rebuild them. The converter on the server (IsNull() / In() / ArrayOverlap()) is for queries only — don't put raw TypeORM operators into conditions that need to reach the client.

platform_admin abilities

platform_admin accounts (Naalya's own operators, not a school's users) skip role lookups entirely — resolveContext does nothing for them — and get a hardcoded ability:

apps/server/src/app/auth/ability/ability-factory.service.ts
case UserType.PLATFORM_ADMIN:
  can(Action.MANAGE, Resource.SCHOOL);
  can(Action.MANAGE, Resource.CURRICULUM);
  can(Action.IMPERSONATE, Resource.ALL);
  can(Action.MANAGE, Resource.USER, { type: UserType.PLATFORM_ADMIN });
  break;

Granting or revoking a role on a platform operator does nothing — their abilities are static, defined entirely in this branch.

Impersonation

Impersonation is gated by a normal permission, @RequirePermissions({ action: Action.IMPERSONATE, resource: Resource.USER }). A school Super Admin satisfies it via their role grant; a platform operator via IMPERSONATE + ALL. The flow refuses to target yourself or another super-admin (platform operators bypass that last check), then mints a token for the target and stamps the admin's id into the JWT act claim:

apps/server/src/app/auth/auth.service.ts
const accessToken = await this.tokenService.generateAccessToken(target, undefined, admin.sub);
// ...refresh token also carries admin.sub; both write an IMPERSONATE_START audit entry

The impersonated session runs with the target's full ability and scope — there's no merged or partial mode. POST /auth/impersonate/stop revokes only the impersonation token family (it reads payload.act), leaving the target's own sessions intact, and writes an IMPERSONATE_STOP entry.

Impersonation is a full identity swap

While impersonating, every guard and scope check runs against the target's ability, not a blend. The only trace of the admin is the act claim on the token — which is exactly what impersonate/stop reads to unwind cleanly. See Auditing for where the start and stop events land.

Where to go next

On this page