Skip to Content

NestJS

Not on npm yet

npm install @evanion/acl does not resolve. The package is private: true, so a release run versions and tags it without publishing: npm cannot configure a trusted publisher for a package that does not exist on the registry, and the first version has to go up by hand.

apps/shop-api in this repository is the Nest service that authors Baize’s matrix, enforces it, and publishes it to the three frontends. Every fence below is a region of that app and runs under nx test @evanion/shop-api. There is no @evanion/nestjs-acl: the app calls @evanion/acl directly.

The concept. A Nest request passes a guard before the row is fetched and a service after it, so one write decides twice.

What you get. A guard that refuses a subject holding no game.declare at all, and a service that decides on Brass: Birmingham.

Why you want it. A guard asking an object-dependent rule with no instance gets unevaluable back, which is not a refusal.

How the library gets you there. readsObject reports which permissions need the row, which is what puts each decision where the data is.

What apps/shop-api wires up

apps/shop-api runs four pieces, and every fence on this page is a region of one of them:

PieceWhere it livesWhat it does
the matrixbuilt at boot behind an injection tokenholds every rule the service enforces
the subjectAsyncLocalStoragecarries the request’s actor to every caller
AclGuarda global guardrefuses what it can refuse before the read
GamesServicethe service layerdecides game.declare on the loaded title

A CanActivate guard runs before the controller method and holds the subject with no row. The service runs after the read and holds both. access.readsObject(key, action) tells the guard which permissions it can finish, so the guard refuses those and passes the others on. canFields and pickAllowedFields then close the write in the service.

Baize grants game.declare to an operator whose own shop lists the title. A guard called with no title answers unevaluable, and an app reading that as a grant lets an operator in Gothenburg edit a Stockholm listing.

Two seats, and only one of them decides

A seat is a place in the request pipeline where an app can call can, and a Nest write passes two of them: the guard, before the row is fetched, and the service, after. A guard holds the subject and no row, so it can ask whether this subject holds game.declare at all and cannot ask whether it holds it on Brass: Birmingham. An object-dependent rule called with no instance comes back unevaluable, naming the paths it could not read.

The service after the read is the enforcement point

The row-level decision belongs in the service, after the read. A guard that passed is a guard that could not decide, and an app that reads that as a grant leaves an object-dependent permission unenforced. Keep both seats.

The two seats one write passes through. AclGuard holds the subject and no row, so it refuses a permission whose rules read only subject paths and passes on one that readsObject reports as object-dependent; GamesService decides that one after the read, against the title it loaded.

Building Baize’s matrix once at boot

ShopSubject carries id, roles and shop. Game, OrderDraft, TelemetryEvent and Stock are the four row shapes the four keys decide over, SHOP_SCHEMA states their field types for the document, AVAILABILITIES is the catalogue’s four availability tokens, and SHOP_MATRIX_VERSION is the string a frontend compares its copy against.

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));

Six permissions, each carrying visibility except inventory.read, which shop-api keeps to itself. game.declare lets an operator set availability; game.reprice adds price and belongs to a manager. Both read the ownership axis object.shop === subject.shop.

SHOP_MATRIX is the document that builder produced and ShopObjects maps each key to its row shape. hydratePolicy adopts it, because this process wrote it:

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

hydratePolicy validates, deep-clones and deep-freezes the document. An unknown field, or two permissions sharing a key, stops the boot.

The module that binds the matrix

AclModule.forRoot() returns a global: true DynamicModule with a useValue provider under a package-namespaced string token. ACL_ACCESS is that token, AclGuard is the guard two sections below, and PolicyController is the endpoint at the end of this page:

@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 token is a namespaced string:

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

The provider sits in a global module, where a bare 'ACL_ACCESS' collides in silence. A symbol is identity-based, so two copies of the package mint two tokens and Nest fails to resolve one of them. APP_GUARD binds the guard to every route, and the @Requires metadata below decides which routes it gates.

Resolving the subject in a Nest request

SubjectService holds the request’s subject in an AsyncLocalStorage singleton. Scope.REQUEST propagates upward through the injection graph: every dependent of a request-scoped provider becomes request-scoped, is rebuilt per request, and never receives onModuleInit. GamesService and InventoryClient both depend on the subject, so request scope on it would re-scope the application.

@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 middleware that fills it types its arguments against node:http, so one class works under either adapter. SHOP_SUBJECT_HEADER is X-Shop-Subject, subjectFrom parses it, and ANONYMOUS_SUBJECT is a shopper who works for no shop:

@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); } }

This demo reads a header, and a deployment must not

Baize has no session layer, so a caller states who it is and shop-api believes it. can authorizes the bag it is handed and has no channel to ask where the bag came from, so a subject assembled from a header, a query parameter or a posted field is the confused deputy. In production this middleware sits behind whatever verifies the session and reads the verified actor. Nothing else in the app changes, because every caller reads SubjectService.current().

The guard refuses what it can refuse

@Requires writes the permission onto the handler under the namespaced metadata key REQUIRES_PERMISSION, and the guard reads it back. RequiredPermission types key against ShopObjects, so a route naming a kind the matrix does not carry fails to compile:

export const Requires = (key: RequiredPermission['key'], action: string) => SetMetadata<string, RequiredPermission>(REQUIRES_PERMISSION, { key, action });
@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; // `unevaluable` means the rules read fields of a row this seat does not // hold. The service decides it after the read, so the request continues. if (this.access.readsObject(required.key, required.action)) return true; throw new ForbiddenException(decision.reason); } }

readsObject answers true when any allow rule or any deny rule of that permission reads an object.* path, on either operand. order.create reads the subject’s roles alone, so a customer-less caller is refused here and never reaches OrdersController. game.declare reads the title’s shop, so the guard passes it on for GamesService to finish.

The guard does not catch a @Requires naming an action the document has no permission for: key is typed and action stays a string. On an Access built open, can throws UnknownPermissionError, which Nest renders as a 500.

The service decides on the row

GamesService holds SubjectService, the catalogue and ACL_ACCESS. GameDeclaration is the request body, findByUrn loads the title, and the action is chosen from the body because the two write actions carry different field allow-lists:

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); }

decision.action.allowed is checked first, so the refusal is a ForbiddenException and never an exception thrown out of pickAllowedFields. A hand-written read of the field map on !== 'denied' writes the unevaluable fields too. A Nest @Body() is the mass-assignment shape, because a caller puts whatever it likes in it.

A whitelisting ValidationPipe answers a different question: the pipe says the key is a legal field of this endpoint, and the decision says this subject may set it on this row. Keep both.

Publishing the matrix to the frontends

PolicyController answers GET /api/policy with the reduced document:

@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') emits the permissions marked visibility: 'public', each kept whole, and drops inventory.read. The three frontends fetch this once and evaluate it locally, so no decision any of them makes waits on a network call. Each of them decides again anyway, and so does this service on the next request it receives.

Testing it

apps/shop-api stubs the request context with useValue on the class token, the idiom the app already uses elsewhere:

const module = await Test.createTestingModule({ controllers: [GamesController], providers: [ GamesService, { provide: ACL_ACCESS, useValue: buildShopAccess() }, { provide: SubjectService, useValue: { current: () => subject } }, ], }).compile();
Not executed — nothing here runs it

The matrix is the real one, because a stubbed decision only asserts that the controller called something. acl.guard.spec.ts covers the refusal seat, games.service.spec.ts the row seat, and orders.e2e.spec.ts boots the real AppModule over a real port.

Where to go next

Last updated on