Skip to Content

Unreleased changes

These pages document main, which has changes 0.1.2 does not. npm install @evanion/acl gives you 0.1.2, documented here. Values on these pages are produced by running the current source on every build.

Express

A middleware binds access.authorize(subject) from @evanion/acl to res.locals, and each Express route handler calls canFields or can on the row it loaded.

An Express app that decides with @evanion/acl

Baize’s catalogue API lets an operator change the availability of a title their own shop lists. The app below is the whole of it: one policy, two middlewares, one PATCH route, and two requests from Ines, an operator at the Stockholm shop.

The policy passes policy() a third type argument, { game: Action | 'declare' }. Action is the default four verbs, create, read, update and delete, and the map adds declare for game. p.contains('subject.roles', 'operator') holds when the subject’s roles array holds 'operator', and .fields({ fields: ['availability'] }) limits what declare may write to availability.

import { } from 'node:events'; import type { AddressInfo } from 'node:net'; import from 'express'; import { , , type } from '@evanion/acl'; type = { : string; : string[]; : string }; type = { : string; : string; : string; : number }; // Built once, at module scope, and evaluated for every request. const = < , { : }, { : | 'declare' } >() .('game', () => .( 'declare', .('subject.roles', 'operator'), .('object.shop', 'subject.shop'), ) .({ : ['availability'] }), ) .(); declare module 'express-serve-static-core' { interface Locals { : ; : <typeof .>; } } // Your session layer: Passport, a JWT check, a cookie store. Here, a token the // server issued at sign-in. const = new ([ [ 'session-ines', { : 'staff:ines', : ['operator'], : 'stockholm' }, ], ]); const : express. = (, , ) => { const = .(.('authorization') ?? ''); if (!) return .(401); .. = ; (); }; // The subject is what requireSession verified. `now` is pinned once, so every // `before` or `after` condition in this request reads the same instant. const : express. = (, , ) => { .. = .(.., { : new () }); (); }; const = new <string, >([ [ 'urn:game:wingspan', { : 'urn:game:wingspan', : 'stockholm', : 'in-stock', : 59900, }, ], [ 'urn:game:gloomhaven', { : 'urn:game:gloomhaven', : 'gothenburg', : 'in-stock', : 149900, }, ], ]); const = .(); .('/:urn', (, ) => { const = .(..); if (!) return .(404); // Every key of the body is decided, including keys a game never holds. const = ...( 'game', 'declare', , 'write', ., ); if (!..) return .(403); const = (, .); if (.(). === 0) return .(400); .(., { ..., ... }); return .(204); }); // Mounted in this order, every route under `api` runs behind both. const = .(); .(); .(); .('/games', ); const = ().(.()).('/api', ).(0); await (, 'listening'); const { } = .() as AddressInfo; const = (: string, : <>) => (`http://localhost:${}/api/games/${}`, { : 'PATCH', : { : 'session-ines', 'content-type': 'application/json', }, : .(), }); const = await ('urn:game:wingspan', { : 'preorder', : 1, }); .; // -> 204 .('urn:game:wingspan')?.; // -> 'preorder' .('urn:game:wingspan')?.; // -> 59900 const = await ('urn:game:gloomhaven', { : 'preorder', }); .; // -> 403 .();

Ines gets 204 for Wingspan, which Stockholm lists, and the catalogue now says preorder. The price in her body is dropped, because game.declare allows availability and nothing else. Gloomhaven is Gothenburg’s, so the same request gets 403 and writes nothing.

PieceWho writes itWhat it does
accessyou, once, at module scopeholds the rules, validated, deep-cloned and deep-frozen at build
requireSessionyour session layerverifies the caller and sets res.locals.user
bindAccessyou, in five linesbinds that user and one now with access.authorize
the routeyou, once per routeloads the row, decides on it, writes what pickAllowedFields keeps

@evanion/acl ships no Express adapter, because one over authorize would add a re-export and a name and nothing else.

Mount order decides which Express routes acl guards

router.use is positional. A route registered on api above api.use(bindAccess) runs with res.locals.access unset: a handler that calls it throws a TypeError and answers 500, and a handler that never calls it answers with no decision made.

Registration order decides which routes run behind bindAccess. Every request under api passes requireSession and then bindAccess before a route runs, so a handler finds the subject on res.locals; a route registered above bindAccess runs with res.locals.access unset.

Mount requireSession and bindAccess on the router that carries the routes, before the routes: api.use(requireSession), api.use(bindAccess), then api.use('/games', games). @evanion/acl cannot see the route table, so it cannot close a route registered above bindAccess. The app’s own test over its route table can.

Binding the acl subject from the Express session

bindAccess passes res.locals.user, the caller requireSession verified, and nothing from the request itself. can authorizes the subject it is handed and cannot ask where that subject came from, so a subject read off a header, a query string or the body is the caller’s claimed identity, authorized as stated.

access.authorize(subject, { now }) returns a handle bound to that subject, so its can and canFields take no subject argument. A rule can carry a time condition, before or after an instant. Pinning now in the middleware gives every such condition one instant for the whole request, so two handlers in one request cannot fall on opposite sides of a boundary that closed between them.

Deciding an Express write on the row

canFields('game', 'declare', game, 'write', req.body) decides every key of the body, including keys a game never holds, which is what closes mass assignment. 'write' asks which keys the subject may set; 'read' asks which fields it may see. The decision carries action, the answer for game.declare itself, and fields, one answer per key. The route writes pickAllowedFields(decision, req.body) and nothing else from req.body.

  • decision.action.allowed is checked first. pickAllowedFields throws ActionNotAllowedError on a refused action, and Express answers a thrown error with 500.
  • An empty writable means the action stands and no field of the body was allowed. The route answers 400.
  • A filter written by hand on !== 'denied' also writes every field marked unevaluable, meaning its rule read a path the row did not carry, and every key the decision does not carry.

The route loads the row before it decides, because game.declare reads object.shop. Loading the row first is safe while the handler sends nothing from it. An insecure direct object reference (IDOR) happens when the response returns a row the decision refused.

Deciding an Express read, and what a refusal sends back

A read route calls res.locals.access.can('game', 'read', game) and gates on decision.allowed. A row loaded as a projection, a subset of its columns, can come back unevaluable, meaning a rule reads a path the projection did not carry. decision.missing names those paths, so the route loads them and decides again. Branching on reason to grant grants what the engine refused.

A decision carries rule, the id of the rule that decided, beside reason. Send reason and rule to a caller who may read your privilege model, and neither to one who may not.

A browser client of the same API evaluates its own copy of the document, served from a route that returns serialize(access, 'reduced'), the permissions marked visibility: 'public' and nothing else. That copy decides what the client draws, and every Express route that writes decides again on the next request.

Where to go next

Last updated on