Enforcing Access
Reading the ability back two ways — the PermissionsGuard action gate and the getScope / assertCanAccess service helpers.
Once the ability exists, the rest of the request reads it back in two places: the guard checks the action at the door, and the service filters the rows once you're through. This page covers both, then walks the canonical endpoint change.
The guard
PermissionsGuard is the coarse gate. It reads the @RequirePermissions(...) metadata off the handler, builds the ability, and asks CASL the yes/no question. The decorator itself is trivial — it just stamps metadata:
export const RequirePermissions = (...permissions: RequiredPermission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);The guard does the work. Note two things: it stashes the built ability into request-scoped storage so your service can reuse it without rebuilding, and it checks every required permission (multiple pairs are AND-ed).
const ability = await this.abilityFactory.createForUser(user);
request.ability = ability;
const scopeData = permissionScopeStore.getStore();
if (scopeData) scopeData.ability = ability; // hand it to the service layer
const hasPermission = requiredPermissions.every((perm) =>
ability.can(perm.action, perm.resource),
);
if (!hasPermission) {
// ...write an ACCESS_DENIED audit entry, then:
throw new ForbiddenException('Insufficient permissions');
}That permissionScopeStore is an AsyncLocalStorage — a per-request box that the permissionScopeMiddleware seeds empty ({ ability: null }) at the start of every request, and the guard fills. This is how a deeply nested service can reach the ability without threading it through every method argument.
The guard checks the action, not the rows
ability.can(perm.action, perm.resource) ignores conditions when you pass a bare resource string — it only asks "is there any rule for this action+resource?" A campus receptionist passes LIST student at the door even though they should only see their campus. The guard lets them in; the service is responsible for the row filter. Forgetting the service-layer scope check is the single most common authorization bug on this codebase.
getScope & assertCanAccess
Once past the guard, your service pulls the same ability back out of storage through PermissionScopeService and uses it one of two ways.
getScope(action, resource) turns the ability's conditions into a TypeORM WHERE clause for list/read queries. It reads the ability from the store and hands off to caslToTypeOrmWhere:
getScope<T>(action: Action, resource: Resource):
FindOptionsWhere<T> | FindOptionsWhere<T>[] | undefined {
const ability = permissionScopeStore.getStore()?.ability ?? null;
if (!ability) return undefined;
return caslToTypeOrmWhere<T>(ability, action, resource);
}The converter has three outcomes, and the middle one is a fail-closed safety net you must understand:
const canRules = ability.rulesFor(action, resource).filter((r) => !r.inverted);
if (canRules.length === 0) {
throw new ForbiddenException(`No permission for ${action} on ${resource}`); // fail closed
}
if (canRules.some((r) => !r.conditions)) {
return undefined; // unrestricted — e.g. MANAGE+ALL or a school-scoped staffer
}
// every rule has conditions → convert each to TypeORM WHERE, OR them as an arrayundefined means "no filter, return everything" — which is correct for a school-wide admin. But "no matching rule" does not return undefined; it throws. A missing permission is never silently treated as "no filter." The converter also translates CASL operators into TypeORM helpers: null becomes IsNull(), { $in: [...] } becomes In([...]), and an array campus field becomes ArrayOverlap([...]).
assertCanAccess(action, resource, entity) is the other tool — for when you've already loaded a single record and need to confirm this user may touch that specific row. It wraps the entity as a CASL subject so the conditions actually match against its fields:
assertCanAccess(action: Action, resource: Resource, entity: object): void {
const ability = permissionScopeStore.getStore()?.ability ?? null;
if (!ability) return;
if (!ability.can(action, subject(resource, entity) as any)) {
throw new ForbiddenException('Access denied to this resource');
}
}The difference in one line: getScope filters a list before you fetch; assertCanAccess validates one record after you fetch. A third helper, mergeWhere(scope, extraWhere), AND-s a CASL scope with a service-level filter and correctly spreads the extra filter into each element when the scope is an OR-array.
Step 1: gate and scope a list
The two-layer model means every list endpoint needs two changes: the decorator on the controller and the scope read in the service. The controller gates with the decorator — coarse "can they list students at all?":
@RequirePermissions({ action: Action.LIST, resource: Resource.STUDENT })
@Get()
list(@Query() query: StudentListQueryDto) {
return this.studentService.list(query);
}The service does the row filter — it asks for the scope and threads it into the paginated query:
async list(query: StudentListQueryDto) {
const scope = this.permissionScope.getScope<StudentProfileEntity>(
Action.READ, Resource.STUDENT,
);
return this.studentProfileRepository.cursorPaginate('sp', {
...query,
scope: scope ?? undefined, // undefined = unrestricted (school admin)
searchColumns: StudentService.SEARCH_COLUMNS,
});
}A campus receptionist's ability yields { campusId: <theirCampus> }, so the query silently narrows. A school admin's ability has a conditionless rule, so getScope returns undefined and they see everyone.
Step 2: guard a single fetch
For findOne-style reads, fetch first, then assert — this catches "the row exists but isn't yours":
async findOne(id: string): Promise<StudentProfileEntity> {
const profile = await this.studentProfileRepository.findOne({ where: { id } });
if (!profile) throw new NotFoundException('Student profile not found');
this.permissionScope.assertCanAccess(Action.READ, Resource.STUDENT, profile);
return profile;
}If the loaded profile's campusId doesn't match the receptionist's campus condition, assertCanAccess throws a ForbiddenException. The NotFoundException comes first so a non-existent id never leaks as a 403.
Gotchas
- The guard and the service check different things. The guard checks the action; the service checks the rows. Gating an endpoint with
@RequirePermissionsis not enough for a campus-scoped resource — you must also callgetScopeorassertCanAccess. A green guard with no service check is a silent cross-campus leak. getScopefails closed. No matching rule throwsForbiddenException— it never returnsundefined(which would mean "no filter"). A missing permission is never silently "see everything."undefinedfromgetScopemeans unrestricted, not denied. A conditionless rule (school admin,MANAGE + ALL) is "see everything." Passscope ?? undefinedand let the repository run with no extraWHERE.- Conditions are never assigned to a user directly. Admins assign roles at a scope; the factory derives
{ campusId: X }. There's no table or field for per-user conditions. MANAGEimplies every action;MANAGE + ALLimplies everything. A rule forMANAGE studentsatisfies aREAD studentguard check.MANAGE + ALLshort-circuitsapplyStaffAbilitiesentirely — once it's granted, nothing narrows it.- The JWT campus is not the scope. Normal login omits
campusIdfrom the token. Scope is always computed from the role-assignment rows, never read off the JWT. - CASL
$inneeds a real, non-empty array. The guardian path filtersisVerified: truebefore building the$in. An empty$inproduces no usable rule, sogetScopewould throw. The verified-only filter is load-bearing. - platform_admin ignores the role system. Their ability is hardcoded in
buildAbility. Assigning a role to an operator account does nothing.
Where to go next
Scoping Rules
Where the rules the guard and service read come from: campus scoping and merging.
Auth/me & Impersonation
The contract the frontend consumes, plus platform_admin and impersonation.
Add an Endpoint
The end-to-end recipe for adding a route, gating it, and scoping it.
Auditing
Where ACCESS_DENIED events from a failed guard are recorded.
Scoping Rules
How abstract staff grants become campus-scoped row filters — applyStaffAbilities, the campus-scope map, stored conditions, and rule merging.
Auth/me & Impersonation
The /auth/me contract the frontend consumes — permissions vs rules vs scope — plus platform_admin's static abilities and impersonation.