Policy & Attribute-Based Authorization
Role-based access control answers “what role is the user?” but real applications need to answer “can this user act on this resource?”. Attribute-based access control (ABAC) makes authorization decisions from the attributes of the subject, the action, and the resource itself — for example, “an author may update only the articles they own.” In NestJS the idiomatic way to model this is CASL, an isomorphic permission library, wired into a PoliciesGuard driven by a @CheckPolicies decorator. This page shows how to define abilities, build the guard, and enforce attribute-driven rules per route.
Why CASL over plain role checks
A pure RBAC guard hard-codes a roles → routes mapping. As soon as ownership, tenancy, or field-level conditions appear, that mapping explodes. CASL lets you declare abilities as data — can/cannot rules with conditions — and then ask ability.can(action, subject) at any point. The same ability object works on the server, in tests, and even shared with the frontend to hide UI.
| Concern | RBAC guard | CASL / ABAC |
|---|---|---|
| Decision input | Role string | Subject + action + resource attributes |
| Ownership rules | Manual if checks in services | Declarative conditions in ability |
| Field restrictions | Not supported | can('update', 'Article', ['title']) |
| Reuse on frontend | Duplicated logic | Same ability serialized to client |
Install the dependency:
npm install @casl/ability
Defining the ability factory
Model your actions and subjects, then build a factory that produces an AppAbility per user. Conditions reference resource attributes (here, authorId) that CASL matches against the object passed to can.
// casl/ability.factory.ts
import { Injectable } from '@nestjs/common';
import {
AbilityBuilder,
createMongoAbility,
MongoAbility,
ExtractSubjectType,
InferSubjects,
} from '@casl/ability';
import { Article } from '../articles/article.entity';
import { User } from '../users/user.entity';
export enum Action {
Manage = 'manage', // wildcard
Create = 'create',
Read = 'read',
Update = 'update',
Delete = 'delete',
}
type Subjects = InferSubjects<typeof Article | typeof User> | 'all';
export type AppAbility = MongoAbility<[Action, Subjects]>;
@Injectable()
export class AbilityFactory {
createForUser(user: User): AppAbility {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(
createMongoAbility,
);
if (user.role === 'admin') {
can(Action.Manage, 'all'); // full access
} else {
can(Action.Read, Article);
can(Action.Create, Article);
// attribute condition: only your own articles
can(Action.Update, Article, { authorId: user.id });
can(Action.Delete, Article, { authorId: user.id });
cannot(Action.Delete, Article, { published: true }).because(
'Published articles cannot be deleted',
);
}
return build({
detectSubjectType: (item) =>
item.constructor as ExtractSubjectType<Subjects>,
});
}
}
The @CheckPolicies decorator
Policies are expressed as small handler objects (or functions) that receive the ability and return a boolean. A custom decorator attaches them as route metadata so the guard can read them with the Reflector.
// casl/policies.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { AppAbility } from './ability.factory';
export interface IPolicyHandler {
handle(ability: AppAbility): boolean;
}
type PolicyHandlerCallback = (ability: AppAbility) => boolean;
export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback;
export const CHECK_POLICIES_KEY = 'check_policy';
export const CheckPolicies = (...handlers: PolicyHandler[]) =>
SetMetadata(CHECK_POLICIES_KEY, handlers);
Building the PoliciesGuard
The guard reads the handlers, builds the requesting user’s ability, and runs every handler. If any returns false, access is denied. Because abilities are per-request, this runs after your authentication guard has populated request.user.
// casl/policies.guard.ts
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AbilityFactory, AppAbility } from './ability.factory';
import {
CHECK_POLICIES_KEY,
PolicyHandler,
} from './policies.decorator';
@Injectable()
export class PoliciesGuard implements CanActivate {
constructor(
private reflector: Reflector,
private abilityFactory: AbilityFactory,
) {}
canActivate(context: ExecutionContext): boolean {
const handlers =
this.reflector.get<PolicyHandler[]>(
CHECK_POLICIES_KEY,
context.getHandler(),
) ?? [];
const { user } = context.switchToHttp().getRequest();
const ability = this.abilityFactory.createForUser(user);
const allowed = handlers.every((h) => this.exec(h, ability));
if (!allowed) {
throw new ForbiddenException('Insufficient permissions');
}
return true;
}
private exec(handler: PolicyHandler, ability: AppAbility): boolean {
return typeof handler === 'function'
? handler(ability)
: handler.handle(ability);
}
}
The guard above only checks class-level abilities (e.g. “can read any article”). For instance-level checks — “can update this article” — the resource must be loaded first. Do that ownership check inside the service or with a dedicated handler that fetches the entity, since the guard runs before the route handler.
Enforcing policies on routes
Register the factory and apply the guard, then declare policies declaratively per route.
// articles/articles.controller.ts
import {
Controller,
Delete,
Get,
Param,
UseGuards,
} from '@nestjs/common';
import { PoliciesGuard } from '../casl/policies.guard';
import { CheckPolicies } from '../casl/policies.decorator';
import { Action, AppAbility } from '../casl/ability.factory';
import { Article } from './article.entity';
@Controller('articles')
@UseGuards(PoliciesGuard)
export class ArticlesController {
@Get()
@CheckPolicies((ability: AppAbility) => ability.can(Action.Read, Article))
findAll() {
return [{ id: 1, title: 'Hello ABAC' }];
}
@Delete(':id')
@CheckPolicies((ability: AppAbility) =>
ability.can(Action.Delete, Article),
)
remove(@Param('id') id: string) {
return { deleted: id };
}
}
For instance-level ownership, fetch the entity in the handler and re-check with the loaded object:
// articles/articles.service.ts
import { ForbiddenException, Injectable } from '@nestjs/common';
import { AbilityFactory, Action } from '../casl/ability.factory';
import { User } from '../users/user.entity';
import { Article } from './article.entity';
@Injectable()
export class ArticlesService {
constructor(private abilityFactory: AbilityFactory) {}
update(user: User, article: Article, data: Partial<Article>) {
const ability = this.abilityFactory.createForUser(user);
if (ability.cannot(Action.Update, article)) {
throw new ForbiddenException('You can only edit your own articles');
}
return Object.assign(article, data);
}
}
Output:
GET /articles → 200 [{ "id": 1, "title": "Hello ABAC" }]
DELETE /articles/9 (own) → 200 { "deleted": "9" }
DELETE /articles/9 (other user) → 403 { "message": "Insufficient permissions" }
PATCH another user's article → 403 "You can only edit your own articles"
Best Practices
- Keep one
AbilityFactoryas the single source of truth; never scatterif (user.role === ...)checks across services. - Use the wildcard
manage/allrule for admins, and define everything else explicitly with conditions. - Perform class-level checks in the guard, but do instance-level (ownership) checks where the resource is loaded — the guard cannot see entities that don’t exist yet.
- Always quote rule failures with
.because(...)so the UI can surface a meaningful reason. - Share the serialized ability (
@casl/ability/extra’spackRules) with the frontend to keep client and server permissions in sync. - Cover abilities with unit tests —
ability.can()is pure and trivially testable without spinning up the HTTP layer.