Backend Security

Role-Based Access Control with CASL in a NestJS Backend

Hanabi Technologies
August 25, 2025
4 min read
Role-Based Access Control with CASL in a NestJS Backend

Modern back-ends rarely expose every endpoint to every client. Whether you are building an admin dashboard or a partner API, access should depend on who is making the request. CASL provides a clean, declarative way to model permissions. Below is an end-to-end walkthrough of how to structure a NestJS module for CASL-powered role-based access control (RBAC) and how those abilities are enforced at runtime.

1. Defining Actions

Start by enumerating the CRUD actions your application understands. This keeps policy checks consistent across the codebase.

1// src/types/permission.ts
2export enum Action {
3  Manage = "manage",
4  Create = "create",
5  Read = "read",
6  Update = "update",
7  Delete = "delete",
8}
9
10export interface PolicyHandlerClass {
11  handle(ability: AppAbility): boolean;
12}
13
14export type PolicyHandler = (ability: AppAbility) => boolean;

2. Ability Factory

The AbilityFactory centralizes how abilities are built for each authenticated user. It wires up role-specific rules and any cross-role defaults.

1// src/modules/access/ability.factory.ts
2@Injectable()
3export class AbilityFactory {
4  constructor(
5    private readonly siteModel: Model<SiteDocument>,
6    private readonly deviceModel: Model<DeviceDocument>,
7  ) {}
8
9  async defineAbility(user: UserDocument): Promise<AppAbility> {
10    const builder = new AbilityBuilder<AppAbility>(createMongoAbility);
11
12    switch (user.role) {
13      case UserRole.Admin:
14        defineAdminAbilities(builder, user);
15        break;
16      case UserRole.Partner:
17        const siteIds = await this.loadSitesFor(user);
18        const deviceIds = await this.loadDevicesFor(siteIds);
19        definePartnerAbilities(builder, user, siteIds, deviceIds);
20        break;
21      default:
22        builder.cannot(Action.Manage, "all");
23    }
24
25    // Every user may read their own profile
26    builder.can(Action.Read, UserEntity, { id: user.id });
27
28    return createMongoAbility<[Action, Subjects]>(builder.rules, {
29      detectSubjectType: (item): ExtractSubjectType<Subjects> =>
30        item.constructor,
31    });
32  }
33}

3. Role-Specific Ability Definitions

Each role function receives the AbilityBuilder and adds its own rules. Here are two examples.

1// src/modules/access/roles/admin.ability.ts
2export function defineAdminAbilities(
3  builder: AbilityBuilder<AppAbility>,
4  user: UserDocument,
5) {
6  const { can, cannot } = builder;
7
8  can(Action.Manage, "all"); // full access by default
9
10  const { sites, devices, accounts, partners } = user.permissions ?? {};
11  if (!sites) cannot(Action.Manage, SiteEntity);
12  if (!devices) cannot(Action.Manage, DeviceEntity);
13  if (!accounts) cannot(Action.Manage, AccountEntity);
14  if (!partners) cannot(Action.Manage, PartnerEntity);
15}

4. The CASL Module

The module bundles both factories and makes them globally available.

1// src/modules/access/access.module.ts
2@Global()
3@Module({
4  imports: [
5    MongooseModule.forFeature(
6      [
7        { name: SiteEntity.name, schema: SiteSchema },
8        { name: DeviceEntity.name, schema: DeviceSchema },
9      ],
10      DB_CLUSTER,
11    ),
12  ],
13  providers: [AbilityFactory, DeviceAbilityFactory],
14  exports: [AbilityFactory, DeviceAbilityFactory],
15})
16export class AccessModule {}

5. Guarding Requests

The PoliciesGuard retrieves policy functions and validates them against the caller's ability. If no user or device is attached to the request, access is denied.

1// src/modules/access/guards/policies.guard.ts
2@Injectable()
3export class PoliciesGuard implements CanActivate {
4  constructor(
5    private readonly reflector: Reflector,
6    private readonly abilityFactory: AbilityFactory,
7    private readonly deviceAbilityFactory: DeviceAbilityFactory,
8  ) {}
9
10  async canActivate(ctx: ExecutionContext): Promise<boolean> {
11    const isPublic = this.reflector.get<boolean>(
12      IS_PUBLIC_KEY,
13      ctx.getHandler(),
14    );
15    if (isPublic) return true;
16
17    const checks =
18      this.reflector.get<PolicyCheck[]>(POLICIES_KEY, ctx.getHandler()) ?? [];
19    if (checks.length === 0) return true;
20
21    const req = ctx.switchToHttp().getRequest();
22    const { user, device } = req;
23
24    if (!user && !device) return false;
25
26    const ability = user
27      ? await this.abilityFactory.defineAbility(user)
28      : this.deviceAbilityFactory.defineAbility();
29
30    return checks.every((check) => check(ability));
31  }
32}

Tips for Production

  • Order of rules matters. Always place restrictive cannot rules after broad can rules.
  • Log permission failures. Monitoring denied requests helps detect misconfigurations and intrusion attempts.
  • Test every role. Automated tests for each role's critical paths prevent accidental regressions.
  • Keep abilities small. Extract role definitions into separate files to avoid bloated factories.

By structuring your CASL integration in this modular way, you gain a scalable RBAC system that is easy to reason about and extend — whether adding new roles, migrating existing ones, or opening your API to third-party devices.

More insights

Explore more articles from our AI and development experts