DEV Community

Cover image for Record-level Permissions in HazelJS with @hazeljs/casl
Muhammad Arslan
Muhammad Arslan

Posted on

Record-level Permissions in HazelJS with @hazeljs/casl

@hazeljs/auth ships RoleGuard and TenantGuard — and between those two primitives you can express most access control policies. A route can require a minimum role, and the tenant guard ensures you are never looking at another organisation's data. That covers probably 90% of real-world requirements.

The remaining 10% is the hard part: ownership. Questions like "can this user edit this specific task?" cannot be answered at the route level, because the route executes before you have fetched the record. The answer depends on the data itself — who the record is assigned to, what state it is in, whether the caller owns it.

@hazeljs/casl is the answer to that 10%.


What RBAC alone cannot express

Consider a task management API. The business rules for a regular user role are:

  • Can read any task in their organisation.
  • Can create new tasks.
  • Can update a task — only if it is assigned to them.
  • Can delete a task — only if it is assigned to them and the status is still todo.

The first two rules fit cleanly into RoleGuard. The last two do not — they depend on fields of the specific row being acted on. You would need to fetch the record, check ownership, and throw 403 if the check fails. If you do that ad-hoc in every service method, the logic is scattered across the codebase, untested, and easy to forget.

@hazeljs/casl gives you a single place to write all those rules and a clean API to evaluate them.


The package

npm install @hazeljs/casl
Enter fullscreen mode Exit fullscreen mode

@casl/ability is a peer dependency of @hazeljs/casl and is installed automatically. You do not need to add it to your own package.json — all the types and utilities you need are re-exported directly:

import {
  AbilityFactory,
  MongoAbility,
  AbilityBuilder,
  createMongoAbility,
  subject,
} from '@hazeljs/casl';
Enter fullscreen mode Exit fullscreen mode

What's in the box

Export Purpose
AbilityFactory Abstract base class — extend it to define your permission rules
CaslService Injectable service — call createForUser(user) to build an ability
@Ability() Parameter decorator — injects the pre-built ability into a controller method
PoliciesGuard Factory guard — runs policy handlers before the route executes
@CheckPolicies() Method decorator shorthand for @UseGuards(PoliciesGuard(...))
Re-exports MongoAbility, AbilityBuilder, createMongoAbility, subject from @casl/ability

How it works

1. Define your ability rules

Create a class that extends AbilityFactory and implements createForUser. This is the single place where all permission rules live:

// 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:
        // Regular users — this is where conditional rules shine.
        can('read',   'Task');
        can('create', 'Task');
        can('update', 'Task', { assigneeId: sub });              // own tasks only
        can('delete', 'Task', { assigneeId: sub, status: 'todo' }); // own + unstarted
        can('read',   'User');
        break;
    }

    return build();
  }
}
Enter fullscreen mode Exit fullscreen mode

The third argument to can() is a condition object — CASL evaluates it against the fields of the actual record at check time. The user rule above says: "this user can update a Task if the task's assigneeId equals their user ID". The check is declarative, centralised, and completely separate from the business logic that fetches the record.

2. Register the module

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

@HazelModule({
  imports: [
    CaslModule.forRoot({ abilityFactory: AppAbilityFactory }),
    // ...
  ],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

CaslModule.forRoot() registers AppAbilityFactory and CaslService in the DI container. Every module that needs them can resolve them from that point on.

3. Inject the ability with @Ability()

Here is where @hazeljs/casl does something worth explaining.

In many frameworks, you would inject CaslService into your service, call createForUser(req.user) in each method, and pass the result to the check. That works, but it adds noise to every method signature and couples your business logic layer to the auth system.

@Ability() is a parameter decorator that resolves the ability at the controller level, once per request, and passes the result down as a typed parameter — exactly the same way @Param(), @Body(), and @CurrentUser() work.

The decorator hooks into a generic type: 'custom' injection slot in HazelJS's router parameter injection system. After all guards have run and req.user is populated, the router calls the resolver function stored in the decorator's metadata, which calls CaslService.createForUser(context.user) using the DI container — and the result is injected directly into the method parameter.

// tasks.controller.ts
@UseGuards(RoleGuard('user'))
@Patch('/:id')
update(
  @Ability() ability: AppAbility,   // resolved from req.user, fully typed
  @Param('id') id: string,
  @Body() body: UpdateTaskDto,
) {
  return this.tasksService.update(ability, id, body);
}
Enter fullscreen mode Exit fullscreen mode

The service receives the ability and has no dependency on CaslService:

// tasks.service.ts
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 can match it against the
  // conditional rules defined in AppAbilityFactory.
  if (!ability.can('update', subject('Task', task))) {
    throw Object.assign(
      new Error('You can only update tasks that are assigned to you'),
      { statusCode: 403 },
    );
  }

  return this.tasksRepo.updateTask(id, dto);
}
Enter fullscreen mode Exit fullscreen mode

TasksService now has a single dependency — TasksRepository — and its methods are straightforward to unit-test: construct an ability in the test, pass it in, assert the outcome.


What makes this approach different

Permissions live in one file

The entire access control policy for the application is in app-ability.factory.ts. Rules for every role, for every subject, including conditions. When the requirements change, there is one file to edit.

Compare that to manually sprinkling if (user.role !== 'admin') throw 403 across dozens of service methods. Each of those checks is invisible until you look for it, impossible to reason about as a whole, and easy to get slightly wrong in a different place.

Conditions are evaluated against real data

RoleGuard('admin') answers "is your role admin or higher?" and nothing else. CASL conditions answer "is this record something you are allowed to act on?" The record's assigneeId, status, ownerId, publishedAt — any field — can be part of the rule. The business rule and the data shape it depends on stay in sync naturally.

The ability is a first-class typed value

AppAbility is a fully typed object. The TypeScript compiler knows which (Action, Subject) pairs are valid. An IDE can autocomplete ability.can('update', ...). You cannot accidentally call ability.can('destory', 'Task') without getting a compile error.

Services stay clean

Services accept an AppAbility parameter — a thin, immutable value object — and call ability.can() against records they have already fetched. They do not need to know how the ability was built or where the user came from. The business logic layer and the auth layer are properly separated.

Record-level checks are untestable without the record

This is the key architectural point. A guard runs before the handler and before any data is fetched. That means a guard can never do a record-level check — it does not have the record yet.

@hazeljs/casl embraces this constraint rather than fighting it. PoliciesGuard and @CheckPolicies() handle the checks you can make without data (static subject-type checks like ability.can('create', 'Task')). Record-level checks are explicitly pushed into the service layer, where the data is available.

Request
  │
  ├─ JwtAuthGuard        — is the token valid?
  ├─ TenantGuard         — does this user belong to :orgId?
  ├─ RoleGuard('user')   — is the user's role sufficient?
  │
  ├─ @Ability()          — ability built from req.user, injected into method
  │
  └─ Service method
       ├─ fetch record from DB
       └─ ability.can('update', subject('Task', record))  ← the actual ownership check
Enter fullscreen mode Exit fullscreen mode

What changed in hazeljs-auth-roles-starter

The starter now demonstrates the full four-layer authorization stack. Here is a precise changelog.

New file: src/casl/app-ability.factory.ts

Defines the AppAbility type and all permission rules. Added role-conditional rules for the user role: update own assigned tasks, delete own unstarted tasks. Admins and managers get unconditional manage on tasks.

@hazeljs/casl added as a dependency (no @casl/ability needed)

Before:

"@casl/ability": "^6.7.3",
"@hazeljs/casl": "file:../hazeljs/packages/casl"
Enter fullscreen mode Exit fullscreen mode

After:

"@hazeljs/casl": "file:../hazeljs/packages/casl"
Enter fullscreen mode Exit fullscreen mode

@hazeljs/casl re-exports all the symbols you need. The direct dependency is gone.

app-ability.factory.ts imports from @hazeljs/casl

// before
import { MongoAbility, createMongoAbility, AbilityBuilder } from '@casl/ability';
import { AbilityFactory } from '@hazeljs/casl';

// after — one package, one import
import { AbilityFactory, MongoAbility, AbilityBuilder, createMongoAbility } from '@hazeljs/casl';
Enter fullscreen mode Exit fullscreen mode

tasks.module.tsCaslModule registered, CaslService added to providers

@Module({
  imports: [CaslModule.forRoot({ abilityFactory: AppAbilityFactory })],
  providers: [TenantContext, AppAbilityFactory, CaslService, TasksRepository, TasksService],
  controllers: [TasksController],
})
export class TasksModule {}
Enter fullscreen mode Exit fullscreen mode

tasks.controller.ts@Ability() replaces @CurrentUser()

Before:

import { CurrentUser } from '@hazeljs/auth';
// ...
@Patch('/:id')
update(
  @CurrentUser() user: Record<string, unknown>,
  @Param('id') id: string,
  @Body() body: UpdateTaskDto,
) {
  return this.tasksService.update(user, id, body);
}
Enter fullscreen mode Exit fullscreen mode

After:

import { Ability } from '@hazeljs/casl';
import type { AppAbility } from '../casl/app-ability.factory';
// ...
@Patch('/:id')
update(
  @Ability() ability: AppAbility,
  @Param('id') id: string,
  @Body() body: UpdateTaskDto,
) {
  return this.tasksService.update(ability, id, body);
}
Enter fullscreen mode Exit fullscreen mode

The ability is built once per request inside the framework. The controller does not know about CaslService. The type is concrete — AppAbility — not Record<string, unknown>.

tasks.service.ts — accepts AppAbility, no longer injects CaslService

Before:

constructor(
  private readonly tasksRepo: TasksRepository,
  private readonly casl: CaslService<AppAbility>,
) {}

async update(user: Record<string, unknown>, id: string, dto: UpdateTaskDto) {
  const task    = await this.tasksRepo.findById(id);
  const ability = this.casl.createForUser(user);          // built here every time
  if (!ability.can('update', subject('Task', task))) { ... }
}
Enter fullscreen mode Exit fullscreen mode

After:

constructor(private readonly tasksRepo: TasksRepository) {}

async update(ability: AppAbility, id: string, dto: UpdateTaskDto) {
  const task = await this.tasksRepo.findById(id);
  if (!ability.can('update', subject('Task', task))) { ... }
}
Enter fullscreen mode Exit fullscreen mode
  • CaslService is no longer a dependency of TasksService.
  • createForUser is no longer called in every method.
  • The service is now trivially unit-testable: construct an ability with the rules you want, call the method, assert the outcome.

app.module.tsCaslModule registered globally

CaslModule.forRoot({ abilityFactory: AppAbilityFactory }),
Enter fullscreen mode Exit fullscreen mode

Registering at the root module means CaslService and AppAbilityFactory are available to every module that needs them, without re-importing.


The full authorization stack in one view

POST /orgs/:orgId/tasks
───────────────────────────────────────────────────────────────
1. JwtAuthGuard        verifies Bearer token → populates req.user
2. TenantGuard         user.tenantId === :orgId? → seeds TenantContext
3. RoleGuard('user')   user.role ≥ 'user'? (everyone passes)
4. @Ability()          builds AppAbility from req.user → injected as parameter
5. TasksService.create ability.can('create', 'Task')? (yes for all roles)
   → INSERT into tasks WHERE organizationId = TenantContext.id

PATCH /orgs/:orgId/tasks/:id  (called by a regular 'user')
───────────────────────────────────────────────────────────────
1–4. same as above
5. TasksService.update
   a. SELECT task WHERE id = :id AND organizationId = TenantContext.id
   b. ability.can('update', subject('Task', task))
      → evaluates: task.assigneeId === user.sub?
      → false → throw 403 "You can only update tasks that are assigned to you"
Enter fullscreen mode Exit fullscreen mode

Three of the four layers run before your code is even invoked. The fourth — the record-level ownership check — runs in your service, after you have the data, which is exactly where it belongs.


Summary

Before After
CaslService injected into every service that needs auth checks No auth dependency in services
createForUser(user) called manually in every method Called once by @Ability(), result injected
Ownership rules scattered across service methods All rules in AppAbilityFactory
Raw user: Record<string, unknown> parameter Typed ability: AppAbility parameter
Direct @casl/ability dependency in app Not needed — re-exported from @hazeljs/casl
Service unit tests require mocking CaslService Service unit tests pass in an ability directly

@hazeljs/casl is a thin integration layer. It doesn't reinvent what @casl/ability already does well — it wires it into HazelJS's DI container, guard system, and parameter injection pipeline so it feels like it was always part of the framework.

The starter is at hazeljs-auth-roles-starter. Run it:

cd hazeljs-auth-roles-starter
docker compose up -d
cp .env.example .env
npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

The full curl walkthrough — registering two users in different orgs, creating tasks, verifying ownership rejections — is in the starter's README.md.

Top comments (0)