Skip to Content

NestJS Integration

@evanion/acl 0.1.0 and its patch releases up to 0.1.2, the version on npm. Values on this page were produced by running the source at 40b8f84, a later commit than the 0.1.0 tag, and are not re-executed.

When using @evanion/acl in a NestJS application, you need a way to bridge the gap between Nest’s request lifecycle and the ACL’s subject-based evaluation.

In the Baize shop API (apps/shop-api), this is achieved by combining a global AclGuard for route-level gating and service-level checks for fine-grained object access.

Building the ACL Matrix at Boot

Since @evanion/acl is framework-agnostic, it doesn’t ship a dedicated NestJS module. Instead, the policy is defined as a plain TypeScript file and instantiated when the application boots.

In apps/shop-api, the ShopSubject defines the identity of the user (id, roles, shop), while ShopObjects defines the shapes of the data being accessed.

NameRole
ShopVerbsThe allowed actions for a resource, such as create, read, update, delete, declare, or reprice
SHOP_MATRIX_VERSIONUsed by consumers to ensure they are using a compatible version of the policy
SHOP_SCHEMADefines the field types for subjects and objects, ensuring conditions are checked against valid data
p.alwaysA universal condition that every subject meets
p.contains(path, v)Validates that an array at a specific path contains the value v
.fields, .visibilityRefines the result of the last declared action in the chain
AVAILABILITIESThe valid states a write operation may set for a resource’s availability
const SHOP_POLICY = policy<ShopSubject, ShopObjects, ShopVerbs>({ version: SHOP_MATRIX_VERSION, schema: SHOP_SCHEMA, }) .for('game', (p) => p .allow('read', p.always) .visibility('public') // The operator grant and the manager grant are separate rules, so a // reader strikes one out without touching the other. .allow( 'declare', p.contains('subject.roles', 'operator'), p.eq('object.shop', 'subject.shop'), ) .allow( 'declare', p.contains('subject.roles', 'manager'), p.eq('object.shop', 'subject.shop'), ) .fields({ fields: ['availability'], availability: { targets: [...AVAILABILITIES] }, }) .visibility('public') .allow( 'reprice', p.contains('subject.roles', 'manager'), p.eq('object.shop', 'subject.shop'), ) .fields({ fields: ['availability', 'price'], availability: { targets: [...AVAILABILITIES] }, }) .visibility('public'), ) .for('order', (p) => p .allow('create', p.contains('subject.roles', 'customer')) .visibility('public'), ) .for('telemetry', (p) => p .allow('read', p.contains('subject.roles', 'manager')) .visibility('public'), ) // No marking, so `serialize(access, 'reduced')` drops it: the browsers never // ask about stock, and a contract carrying a rule nobody evaluates is a // larger surface for nothing. .for('inventory', (p) => p.allow('read', p.always));

The policy defines specific permissions like game.declare (for operators) and game.reprice (for managers). These rules typically check that the operator’s shop matches the resource’s shop, preventing a user in one location from modifying data in another.

To ensure the policy document is valid and immutable, hydratePolicy is used during the build process:

export function buildShopAccess(): Access<ShopSubject, ShopObjects> { return hydratePolicy<ShopSubject, ShopObjects>(SHOP_MATRIX); }

hydratePolicy validates the structure, deep-clones the object, and freezes it. If it encounters an unknown field or duplicate permissions, the application will fail to boot.

Integrating the Matrix via a Global Nest Module

To make the ACL matrix available throughout the application, it is bound into a global DynamicModule. This provides the matrix as a provider and registers the necessary guards and controllers.

@Module({}) export class AclModule { /** * Builds the matrix once and returns the module, registered `global: true`. * * Global for the reason `CorrelationModule.forRoot()` states about its own * registration: `AclGuard`, `GamesService`, `InventoryClient` and * `SubjectMiddleware` all resolve `ACL_ACCESS` or `SubjectService` from the * root injector, and without it every one of those modules would import this * one. * * `AclGuard` is bound under `APP_GUARD`, so it runs on every route in the * application and the `@Requires` metadata decides which routes it actually * gates. A route carrying no metadata is waved through by the guard itself. * * Call it once. A second `forRoot()` registers a second `ACL_ACCESS` * provider under the same token and the last import wins. */ static forRoot(): DynamicModule { const accessProvider: Provider = { provide: ACL_ACCESS, // hydratePolicy runs here and nowhere else, so the process holds one // frozen document and a construction failure stops the boot. useValue: buildShopAccess(), }; return { global: true, module: AclModule, controllers: [PolicyController], providers: [ accessProvider, SubjectService, { provide: APP_GUARD, useClass: AclGuard }, ], exports: [accessProvider, SubjectService], }; } }

The APP_GUARD binds the AclGuard to every route. Which specific routes are gated is determined by the @Requires metadata decorator. To avoid collisions with other providers in a global module, a package-namespaced string is used as the token:

export const = '@evanion/shop-api:ACL_ACCESS';

Resolving the Subject from the Request

For @evanion/acl to make a decision, it needs a Subject. In NestJS, this subject is resolved from the incoming request and stored in AsyncLocalStorage via a SubjectService.

Using AsyncLocalStorage allows the subject to be accessed anywhere in the request chain without forcing every dependent provider to become request-scoped, which would degrade performance and break onModuleInit hooks.

@Injectable() export class SubjectService { private readonly storage = new AsyncLocalStorage<ShopSubject>(); /** * Runs `callback` with `subject` as the actor. Everything it awaits, * schedules or calls reads that subject, and overlapping requests stay * isolated. * * `SubjectMiddleware` does this per request. Call it directly for work no * request drives, such as a queue consumer or a seeding script. */ run<T>(subject: ShopSubject, callback: () => T): T { return this.storage.run(subject, callback); } /** * The subject of the surrounding context, or `undefined` outside one. * * It invents no subject. A caller that needs one refuses when this answers * `undefined`, because a decision made against a subject nobody stated is a * decision made against nothing. */ current(): ShopSubject | undefined { return this.storage.getStore(); } }

The SubjectMiddleware is responsible for populating this service. It is applied to all routes in AppModule.configure. The middleware parses the subject from a request header (X-Shop-Subject), falling back to a default “shopper” identity if no valid subject is provided:

@Injectable() export class SubjectMiddleware implements NestMiddleware { constructor(private readonly subjects: SubjectService) {} use( req: IncomingMessage, _res: ServerResponse, next: (error?: unknown) => void, ): void { const header = singleValue(req.headers[SHOP_SUBJECT_HEADER.toLowerCase()]); this.subjects.run(subjectFrom(header), next); } }

Security Note: Trusting Headers

In this demo, the subject is read directly from a header for simplicity. In a production environment, this middleware should read the actor identity from a verified session layer. Because can only authorizes the subject it is given, it relies on the middleware to provide a trusted identity.

Coordinating the Guard and the Service

A typical write request in NestJS passes through two distinct “seats” where ACL checks occur: the Guard and the Service.

  1. The Guard (AclGuard): Runs before the controller. It only has access to the Subject. It can decide on permissions that depend solely on the user’s identity (e.g., “Is this user an admin?”).
  2. The Service (GamesService): Runs after the resource has been loaded from the database. It has access to both the Subject and the Object (the row). It decides on permissions that depend on the data (e.g., “Does this operator belong to the same shop as this game?”).
SeatContextDecision Capability
AclGuardSubject onlyPermissions whose rules only read subject.*
GamesServiceSubject + Object rowPermissions that read object.* (e.g., game.declare or game.reprice)

The AclGuard uses access.readsObject(key, action) to determine if a permission requires an object to be evaluated. If a permission is object-dependent, the guard allows the request to proceed to the controller, leaving the final decision to the service.

The two-stage authorization flow in NestJS. The Guard handles identity-based checks, while the Service handles data-based checks after the resource is loaded.

Gating Routes with @Requires

The @Requires decorator attaches the required permission to the handler. To ensure type safety, the permission key is typed against ShopObjects, meaning a route requesting a permission for a non-existent object kind will fail to compile.

export const Requires = (key: RequiredPermission['key'], action: string) => SetMetadata<string, RequiredPermission>(REQUIRES_PERMISSION, { key, action });

The AclGuard then evaluates this requirement:

@Injectable() export class AclGuard implements CanActivate { constructor( private readonly reflector: Reflector, private readonly subjects: SubjectService, @Inject(ACL_ACCESS) private readonly access: Access<ShopSubject, ShopObjects>, ) {} canActivate(context: ExecutionContext): boolean { const required = this.reflector.getAllAndOverride< RequiredPermission | undefined >(REQUIRES_PERMISSION, [context.getHandler(), context.getClass()]); if (!required) return true; const subject = this.subjects.current(); // SubjectMiddleware runs on every route and falls back to the anonymous // subject, so this is reached only where the middleware did not run, which // is a request nothing resolved an actor for. if (!subject) throw new UnauthorizedException(); const decision = this.access.can(subject, required.key, required.action); if (decision.allowed) return true; // A permission whose rules read `object.*` is decided by the service on the // row it loads, whatever `can` answered here without one. if (this.access.readsObject(required.key, required.action)) return true; throw new ForbiddenException(decision.reason); } }

For example, order.create may only check the subject’s roles. If the user is not a customer, the guard refuses the request immediately, and the controller never executes. Conversely, game.declare requires the title’s shop ID, so the guard defers the decision.

Fine-Grained Decisions in the Service

Once the service loads the resource, it performs the final check using canFields. This allows the service to not only decide if the action is allowed but also which specific fields the subject is permitted to modify.

declare(urn: string, proposed: GameDeclaration): Game { const game = this.findByUrn(urn); const subject = this.subjects.current(); if (!subject) throw new UnauthorizedException(); const action = 'price' in proposed ? 'reprice' : 'declare'; const decision = this.access.canFields( subject, 'game', action, game, 'write', proposed, ); if (!decision.action.allowed) { throw new ForbiddenException(decision.action.reason); } // pickAllowedFields keeps the keys the decision marked `allowed` and // nothing else, so a denied key and an unevaluable key are both withheld. const writable = pickAllowedFields(decision, proposed); if (Object.keys(writable).length === 0) { throw new BadRequestException('No field of this declaration is writable'); } this.declared.set(urn, { ...this.declared.get(urn), ...writable }); return this.findByUrn(urn); }

The service checks decision.action.allowed first. If refused, it throws a ForbiddenException. If allowed, it uses the decision to whitelist the fields from the request body, ensuring that the user cannot modify restricted fields even if they are present in the payload.

This logic is verified against the real policy matrix in tests, ensuring that an operator from one shop cannot modify a title belonging to another:

it('lets an operator change a title their own shop lists', () => { const service = serviceFor(STOCKHOLM_OPERATOR); const game = service.declare(STOCKHOLM_TITLE, { availability: 'preorder', }); expect(game.availability).toBe('preorder'); expect(service.findByUrn(STOCKHOLM_TITLE).availability).toBe('preorder'); }); it('refuses the same operator on a title another shop lists', () => { const service = serviceFor(STOCKHOLM_OPERATOR); expect(() => service.declare(GOTHENBURG_TITLE, { availability: 'preorder' }), ).toThrow(ForbiddenException); });

Publishing the Policy Matrix

To allow frontends to implement “optimistic” UI (e.g., hiding a button the user can’t use), the PolicyController publishes a reduced version of the matrix.

@Controller('policy') export class PolicyController { constructor( @Inject(ACL_ACCESS) private readonly access: Access<ShopSubject, ShopObjects>, ) {} @Get() read(): PolicyDocument { const matrix = serialize(this.access, 'reduced'); return { version: matrix.version, matrix }; } }

serialize(access, 'reduced') removes internal-only permissions and keeps only those marked as visibility: 'public'. While the frontend uses this for UI hints, the backend always re-evaluates every request to ensure security.

Verifying the Integration

Testing the NestJS wiring requires the real ACL matrix to ensure that the guards and services are interacting correctly with the policy.

const controllerFor = async (subject: ShopSubject) => { const module = await Test.createTestingModule({ controllers: [GamesController], providers: [ GamesService, FeaturesService, { provide: ACL_ACCESS, useValue: buildShopAccess() }, { provide: SHOP_FEATURES, useValue: buildShopFeatures() }, { provide: SubjectService, useValue: { current: () => subject } }, ], }).compile(); return module.get(GamesController); };

By building a testing module with the real ACL_ACCESS provider, the specs verify the controller and the service against the real policy. acl.guard.spec.ts covers the guard, and orders.e2e.spec.ts boots the whole AppModule.

Where to go next

Last updated on