CASL Package
@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
AbilityFactoryclass — 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 typedAppAbilityrather than a raw user objectPoliciesGuard/@CheckPolicies(): For static subject-type checks (e.g.can('create', 'Task')) that can be enforced before the route handler executes- Zero extra dependencies:
@casl/abilityis bundled with@hazeljs/casland 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:#fffKey Components
AbilityFactory: Abstract base class — extend to define what each role can do, including field conditionsCaslService: Injectable service —createForUser(user)builds the ability from the current user's payload@Ability(): Parameter decorator — resolvesCaslService.createForUser(req.user)once per request and injects the resultPoliciesGuard: Factory guard —@UseGuards(PoliciesGuard(handler))runs policy checks before the handler@CheckPolicies(): Method decorator shorthand forPoliciesGuard- 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
| Situation | Recommendation |
|---|---|
| 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 / interceptor | Inject 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)
| Option | Type | Description |
|---|---|---|
abilityFactory | Type<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/caslin action in the hazeljs-auth-roles-starter example project - Combine with Auth for the full JWT + RBAC + ABAC stack
- Use TypeORM with
TenantContextfor automatic tenant-scoped queries