CASL Package

npm downloads

@hazeljs/casl integrates CASL into HazelJS to provide attribute-based access control (ABAC) — the ability to answer questions like "can this user edit this specific record?" that role-based guards alone cannot express.

Where RoleGuard answers "is your role high enough?", CASL answers "does this particular record belong to you, and are you allowed to act on it in its current state?" The two layers are designed to work together: roles guard the route, CASL guards the data.

Purpose

  • Record-level permissions: Check can('update', subject('Task', task)) against the actual database row, including any field conditions (assigneeId, status, ownerId, etc.)
  • Centralised rules: All permissions for every role live in one AbilityFactory class — one file to read, one file to change
  • @Ability() decorator: Injects the current user's pre-built ability directly into a controller method parameter — services receive a typed AppAbility rather than a raw user object
  • PoliciesGuard / @CheckPolicies(): For static subject-type checks (e.g. can('create', 'Task')) that can be enforced before the route handler executes
  • Zero extra dependencies: @casl/ability is bundled with @hazeljs/casl and re-exported — no separate install needed

Architecture

graph TD
  A["Incoming Request"] --> B["JwtAuthGuard<br/>(verify token)"]
  B --> C["TenantGuard<br/>(org isolation)"]
  C --> D["RoleGuard<br/>(minimum role)"]
  D --> E["@Ability() decorator<br/>builds AppAbility from req.user"]
  E --> F["Controller Method"]
  F --> G["Service Method<br/>fetch record from DB"]
  G --> H["ability.can('update', subject('Task', record))<br/>record-level ownership check"]
  H -->|"✓ allowed"| I["Execute & return"]
  H -->|"✗ denied"| J["throw 403"]

  style A fill:#3b82f6,stroke:#60a5fa,stroke-width:2px,color:#fff
  style B fill:#8b5cf6,stroke:#a78bfa,stroke-width:2px,color:#fff
  style C fill:#8b5cf6,stroke:#a78bfa,stroke-width:2px,color:#fff
  style D fill:#8b5cf6,stroke:#a78bfa,stroke-width:2px,color:#fff
  style E fill:#f59e0b,stroke:#fbbf24,stroke-width:2px,color:#fff
  style F fill:#10b981,stroke:#34d399,stroke-width:2px,color:#fff
  style G fill:#10b981,stroke:#34d399,stroke-width:2px,color:#fff
  style H fill:#ef4444,stroke:#f87171,stroke-width:2px,color:#fff
  style I fill:#10b981,stroke:#34d399,stroke-width:2px,color:#fff
  style J fill:#ef4444,stroke:#f87171,stroke-width:2px,color:#fff

Key Components

  1. AbilityFactory: Abstract base class — extend to define what each role can do, including field conditions
  2. CaslService: Injectable service — createForUser(user) builds the ability from the current user's payload
  3. @Ability(): Parameter decorator — resolves CaslService.createForUser(req.user) once per request and injects the result
  4. PoliciesGuard: Factory guard — @UseGuards(PoliciesGuard(handler)) runs policy checks before the handler
  5. @CheckPolicies(): Method decorator shorthand for PoliciesGuard
  6. Re-exports from @casl/ability: MongoAbility, AbilityBuilder, createMongoAbility, subject

Installation

npm install @hazeljs/casl

@casl/ability is a dependency of @hazeljs/casl and is installed automatically. Import everything you need directly from @hazeljs/casl.

Quick Start

1. Define ability rules

Create a class that extends AbilityFactory. This single file holds all permission rules for every role in your application.

// src/casl/app-ability.factory.ts
import { Injectable } from '@hazeljs/core';
import {
  AbilityFactory,
  MongoAbility,
  AbilityBuilder,
  createMongoAbility,
} from '@hazeljs/casl';

type Action  = 'create' | 'read' | 'update' | 'delete' | 'manage';
type Subject = { assigneeId?: string | null; status?: string } | 'Task' | 'User' | 'all';

export type AppAbility = MongoAbility<[Action, Subject]>;

@Injectable()
export class AppAbilityFactory extends AbilityFactory<AppAbility> {
  createForUser(user: Record<string, unknown>): AppAbility {
    const { can, build } = new AbilityBuilder<AppAbility>(createMongoAbility);

    const role = user['role'] as string;
    const sub  = user['sub']  as string; // the authenticated user's ID

    switch (role) {
      case 'superadmin':
        can('manage', 'all');
        break;

      case 'admin':
        can('manage', 'Task');
        can('manage', 'User');
        break;

      case 'manager':
        can('manage', 'Task');
        can('read',   'User');
        break;

      default: // 'user'
        can('read',   'Task');
        can('create', 'Task');
        // Conditional rules — evaluated against the actual record:
        can('update', 'Task', { assigneeId: sub });              // own tasks only
        can('delete', 'Task', { assigneeId: sub, status: 'todo' }); // own + unstarted
        can('read',   'User');
        break;
    }

    return build();
  }
}

The third argument to can() is a condition object. CASL evaluates it against the fields of the actual database record at check time — not at the route level.

2. Register the module

// app.module.ts
import { HazelModule } from '@hazeljs/core';
import { CaslModule } from '@hazeljs/casl';
import { AppAbilityFactory } from './casl/app-ability.factory';

@HazelModule({
  imports: [
    CaslModule.forRoot({ abilityFactory: AppAbilityFactory }),
    // ...other modules
  ],
})
export class AppModule {}

CaslModule.forRoot() registers AppAbilityFactory and CaslService in the DI container. Any module can now inject CaslService or use @Ability().

3. Inject with @Ability()

@Ability() is a parameter decorator that resolves CaslService.createForUser(req.user) once per request — after all guards have run and req.user is populated — and injects the result directly into the method parameter.

// tasks.controller.ts
import { Controller, Patch, Delete, Param, Body, UseGuards } from '@hazeljs/core';
import { JwtAuthGuard, RoleGuard, TenantGuard } from '@hazeljs/auth';
import { Ability } from '@hazeljs/casl';
import type { AppAbility } from './casl/app-ability.factory';

@UseGuards(JwtAuthGuard, TenantGuard({ source: 'param', key: 'orgId' }))
@Controller('/orgs/:orgId/tasks')
export class TasksController {
  constructor(private readonly tasksService: TasksService) {}

  @UseGuards(RoleGuard('user'))
  @Patch('/:id')
  update(
    @Ability() ability: AppAbility, // ← built from req.user, fully typed
    @Param('id') id: string,
    @Body() dto: UpdateTaskDto,
  ) {
    return this.tasksService.update(ability, id, dto);
  }

  @UseGuards(RoleGuard('user'))
  @Delete('/:id')
  remove(
    @Ability() ability: AppAbility,
    @Param('id') id: string,
  ) {
    return this.tasksService.delete(ability, id);
  }
}

4. Check records in the service

The service receives a typed AppAbility — no CaslService injection needed.

// tasks.service.ts
import { Injectable } from '@hazeljs/core';
import { subject } from '@hazeljs/casl'; // re-exported — no @casl/ability dep needed
import type { AppAbility } from './casl/app-ability.factory';

@Injectable()
export class TasksService {
  constructor(private readonly tasksRepo: TasksRepository) {}

  async update(ability: AppAbility, id: string, dto: UpdateTaskDto): Promise<Task> {
    const task = await this.tasksRepo.findById(id);
    if (!task) throw Object.assign(new Error('Task not found'), { statusCode: 404 });

    // subject() tags the plain object so CASL evaluates conditional rules correctly.
    // admin/manager: can('update', 'Task') — passes unconditionally
    // user:          can('update', task)   — passes only if task.assigneeId === user.sub
    if (!ability.can('update', subject('Task', task))) {
      throw Object.assign(
        new Error('You can only update tasks assigned to you'),
        { statusCode: 403 },
      );
    }

    return this.tasksRepo.updateTask(id, dto);
  }

  async delete(ability: AppAbility, id: string): Promise<void> {
    const task = await this.tasksRepo.findById(id);
    if (!task) throw Object.assign(new Error('Task not found'), { statusCode: 404 });

    if (!ability.can('delete', subject('Task', task))) {
      throw Object.assign(
        new Error('You can only remove your own unstarted tasks'),
        { statusCode: 403 },
      );
    }

    return this.tasksRepo.deleteTask(id);
  }
}

Why subject()? CASL's conditional rules (can('update', 'Task', { assigneeId: sub })) are evaluated against typed subject instances. subject('Task', plainObject) tags the plain database row with its subject type so CASL knows which rules to apply.

@Ability() vs CaslService

SituationRecommendation
Controller → service (standard flow)@Ability() — inject once, pass down
Service called from multiple places (jobs, other services)Inject CaslService, call createForUser(user) manually
Need ability in middleware / interceptorInject CaslService directly

PoliciesGuard and @CheckPolicies()

For static subject-type checks (checks that don't require the record) you can apply guards at the route level.

Function handlers

import { Controller, Post, UseGuards } from '@hazeljs/core';
import { JwtAuthGuard } from '@hazeljs/auth';
import { PoliciesGuard } from '@hazeljs/casl';
import type { AppAbility } from './casl/app-ability.factory';

@UseGuards(JwtAuthGuard)
@Controller('/tasks')
export class TasksController {
  // Only users who can create tasks reach this handler
  @UseGuards(PoliciesGuard<AppAbility>((ability) => ability.can('create', 'Task')))
  @Post('/')
  create(@Body() dto: CreateTaskDto) {
    return this.tasksService.create(dto);
  }
}

@CheckPolicies() shorthand

import { CheckPolicies } from '@hazeljs/casl';

@UseGuards(JwtAuthGuard)
@Controller('/tasks')
export class TasksController {
  @CheckPolicies<AppAbility>((ability) => ability.can('create', 'Task'))
  @Post('/')
  create(@Body() dto: CreateTaskDto) {
    return this.tasksService.create(dto);
  }
}

Class-instance handlers

For handlers that need injected dependencies, implement IPolicyHandler:

import { Injectable } from '@hazeljs/core';
import { IPolicyHandler } from '@hazeljs/casl';
import type { AppAbility } from './casl/app-ability.factory';

@Injectable()
export class CreateTaskPolicyHandler implements IPolicyHandler<AppAbility> {
  handle(ability: AppAbility): boolean {
    return ability.can('create', 'Task');
  }
}

// Usage
@CheckPolicies(new CreateTaskPolicyHandler())
@Post('/')
create(@Body() dto: CreateTaskDto) { ... }

Why record-level checks belong in the service

A guard runs before the handler — before any data is fetched. This means a guard can never evaluate a condition that depends on a database record's fields (assigneeId, status, ownerId). The only place you have the actual record is after you've fetched it in the service.

@hazeljs/casl embraces this constraint:

  • PoliciesGuard / @CheckPolicies() — static checks at the route layer ("can this role create any Task at all?")
  • ability.can(action, subject('Task', record)) — record-level checks in the service, after the fetch
Request
  ├─ JwtAuthGuard    — verify token
  ├─ TenantGuard     — org isolation
  ├─ RoleGuard       — minimum role check
  ├─ @Ability()      — build ability from req.user → inject
  └─ Service
       ├─ fetch record from DB
       └─ ability.can('update', subject('Task', record)) ← ownership check

Full authorization stack example

// Guard × CASL decision matrix for a task management API
//
//  Route          RoleGuard     CASL check in service
//  ─────────────  ────────────  ──────────────────────────────────────────────
//  GET  /tasks    user          (none — list is tenant-scoped by TenantContext)
//  POST /tasks    user          ability.can('create', 'Task')
//  PATCH /:id     user          ability.can('update', subject('Task', task))
//                               → admin/manager: passes
//                               → user: task.assigneeId === user.sub
//  DELETE /:id    user          ability.can('delete', subject('Task', task))
//                               → admin/manager: passes
//                               → user: own + status === 'todo'

API Reference

CaslModule.forRoot(options)

OptionTypeDescription
abilityFactoryType<AbilityFactory<A>>The ability factory class to register

AbilityFactory<A>

Abstract base class. Extend it and implement createForUser:

abstract class AbilityFactory<A extends AnyAbility> {
  abstract createForUser(user: Record<string, unknown>): A;
}

CaslService<A>

class CaslService<A extends AnyAbility> {
  createForUser(user: Record<string, unknown>): A;
}

@Ability()

Parameter decorator. Resolves CaslService.createForUser(req.user) and injects the result. Requires JwtAuthGuard (or any guard setting req.user) to run first.

PoliciesGuard<A>(...handlers)

Factory guard. Returns a CanActivate class. Throws 401 if req.user is missing, 403 if any handler returns false.

@CheckPolicies<A>(...handlers)

Method decorator. Shorthand for @UseGuards(PoliciesGuard(...handlers)).

Re-exports from @casl/ability

import {
  AbilityBuilder,
  createMongoAbility,
  subject,
  type MongoAbility,
  type AnyAbility,
} from '@hazeljs/casl';

What's Next?

  • See @hazeljs/casl in action in the hazeljs-auth-roles-starter example project
  • Combine with Auth for the full JWT + RBAC + ABAC stack
  • Use TypeORM with TenantContext for automatic tenant-scoped queries