Enforcement
Declarative authorization with @Can and the CanGuard, role checks with @Roles and the RolesGuard, and the optional can-endpoint controller.
You've written policies and gates — the rules. Enforcement is how you apply them at the edge of your app: declaratively on routes with decorators, so authorization is visible right next to the handler it protects.
AuthzModule.forRoot() registers two guards as APP_GUARD for you — the CanGuard and the RolesGuard — so the decorators below work with no extra wiring. Both guards are inert on un-annotated routes: a handler without @Can or @Roles is never blocked.
@Can and the CanGuard
@Can(ability, Resource?) declares the ability a route requires. The CanGuard reads it, resolves the resource, and delegates to Gate.authorize — which throws ForbiddenException (HTTP 403) on denial.
import { Controller, Get, Patch, Delete, Param } from '@nestjs/common';
import { Can } from '@dudousxd/nestjs-authz';
import { Post } from './post.entity.js';
@Controller('posts')
export class PostsController {
@Get(':id')
@Can('view', Post) // resolves Post by :id, runs PostPolicy.view(currentUser, post)
show(@Param('id') id: string) {}
@Patch(':id')
@Can('update', Post)
update(@Param('id') id: string) {}
@Delete(':id')
@Can('delete', Post)
remove(@Param('id') id: string) {}
}For an instance ability, the guard:
- Reads the
@Canmetadata (ability+resourceclass). - Resolves an instance via the registered
ResourceResolver— by default, loading by the route:id. - Calls
gate.authorize(ability, instance), which runs the matching policy method.
If the resolver returns undefined (the resource wasn't found), the guard denies with ForbiddenException rather than calling your policy with a bogus resource. If no resolver is registered at all for an instance ability, it throws ResourceResolverMissingException. The default IdParamResourceResolver is registered automatically, so a clean install already has one — see Resource resolution.
Class-level abilities
Some abilities — create, viewAny — have no instance to load. Pass { classLevel: true } so the guard skips resource resolution and dispatches against the resource class:
@Post()
@Can('create', Post, { classLevel: true }) // runs PostPolicy.create(currentUser) — no instance loaded
create() {}CanOptions currently has one field:
interface CanOptions {
classLevel?: boolean; // skip resource loading; dispatch against the resource class
}Model-less gates
@Can with no resource class dispatches to an ad-hoc gate by name — no instance, no policy:
@Get('admin')
@Can('access-admin') // runs the gate registered via gate.define('access-admin', ...)
dashboard() {}@Can at the class level
Because @Can is both a method and class decorator, you can guard an entire controller. The guard reads metadata with getAllAndOverride, so a method-level @Can overrides a class-level one:
@Controller('admin')
@Can('access-admin') // applies to every route in this controller…
export class AdminController {
@Get('reports')
reports() {}
@Get('billing')
@Can('access-billing') // …except this one, which requires a different ability
billing() {}
}@Roles and the RolesGuard
@Can is a granular ability check. @Roles is the coarse counterpart: "does the user hold any of these roles?" — the broad "is this a teacher?" question, with no resource involved.
import { Controller, Get } from '@nestjs/common';
import { Roles } from '@dudousxd/nestjs-authz';
@Controller('staff')
export class StaffController {
@Get('queue')
@Roles('admin', 'moderator') // allow if the user is an admin OR a moderator
queue() {}
}The RolesGuard allows the request when the current user holds any of the listed roles, and throws ForbiddenException otherwise. By default, roles are read straight off the user object — user.roles (a string[]) or user.role (a string or string[]) — so role checks work with zero RBAC tables:
// these users both satisfy @Roles('admin'):
{ id: 1, roles: ['admin', 'editor'] }
{ id: 2, role: 'admin' }To derive roles from a different shape, override the resolver in the module:
AuthzModule.forRoot({
resolveRoles: (user) => user.membership?.roleNames ?? [],
});When the optional RoleProvider seam (from an RBAC adapter) is also registered, the gate takes the union of the resolver's roles and the provider's. You can also check roles programmatically:
await this.gate.hasRole('admin');
await this.gate.hasAnyRole(['admin', 'moderator']);
// and on an explicit user:
await this.gate.forUser(someUser).hasAnyRole(['admin']);@Roles and @Can are complementary, not redundant. Use @Roles for broad gatekeeping ("only staff reach this controller") and @Can for fine-grained per-resource decisions ("can this staff member edit this record"). They stack: put @Roles on the controller and @Can on the sensitive methods.
The optional can-endpoint controller
Frontends often need to ask "can I do X?" so they can show or hide a button before the user clicks it. The ideal answer is to ship abilities to the client without a request (via nestjs-authz-inertia shared props, or per-resource can maps). But for the abilities not already hydrated on the client, authz can mount a small fallback endpoint.
It's off by default. Enable it via the canEndpoint option:
AuthzModule.forRoot({
canEndpoint: true, // mount at the default path 'authz/can'
// canEndpoint: 'api/authz/can', // or a custom path
});When enabled, this registers a controller exposing POST {path}. The request and response bodies are typed exports:
import type { CanRequestBody, CanResponseBody } from '@dudousxd/nestjs-authz';
// CanRequestBody:
{ ability: string; resource?: { type: string; id?: string | number } | null }
// CanResponseBody:
{ allowed: boolean }The endpoint runs gate.allows(ability, resource?) for the current (context) user and returns { allowed }. The default mount path is also exported as a constant:
import { DEFAULT_CAN_ENDPOINT_PATH } from '@dudousxd/nestjs-authz'; // 'authz/can'The resource in the request body is only a { type, id } shim — it matches a registered policy only if that policy was authored to read those fields. The endpoint is therefore best suited to class-level abilities and ad-hoc gates; instance-specific decisions should come from shared props or per-resource maps. An unresolved or ambiguous ability fails closed — it returns { allowed: false }, never a 500.
The endpoint is the last-resort target of the codegen-emitted can() helper. If you advertise it under forRootAsync, declare canEndpoint directly on the async options (it's read at module-definition time, before the async factory resolves):
AuthzModule.forRootAsync({
canEndpoint: true,
useFactory: () => ({ /* ... */ }),
});