Skip to Content

Express

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.

Express is the one platform in this band with no app in this repository exercising it. Every fence below is tagged no-run: the shapes are derived from the core’s real surface and from apps/shop-api’s arrangement, and nothing here is executed by a test. Read them as shapes.

The concept. An Express app runs every route in a server process and hands no tree to a browser, so every can it calls enforces.

What you get. A handler that resolves the subject from the verified session and decides on the row it just read.

Why you want it. app.use mounts middleware positionally, so a route registered above bindAccess runs with no evaluator bound and no decision made.

How the library gets you there. @evanion/acl ships no Express adapter, so this page wires requireSession, bindAccess and one can call per handler.

What an Express app with @evanion/acl holds

An Express app hands no client tree anything, so every decision it makes is authoritative and the only question is whether the decision happens at all. This page builds an API over Baize’s catalogue out of three pieces:

  • one matrix at module scope,
  • one middleware binding the subject onto res.locals,
  • a can call in every handler that touches a title.

@evanion/acl imports no framework, and authorize(subject) returns a plain object of closures, so the package ships no Express adapter. An adapter over authorize is a re-export and a name.

Complete mediation is registration order

Two middlewares carry complete mediation here, and neither is part of @evanion/acl. requireSession is whatever your app already uses to verify a caller and put them on req.user: Passport, a JSON Web Token (JWT) check, your own cookie code. bindAccess is about twenty lines you write once, and Binding the subject below is the whole of it. games is the router carrying the catalogue’s own routes, defined in the sections after it.

// server.ts — `requireSession` verifies the caller, `bindAccess` binds what it // verified, and every route under `api` is reached through both. import express from 'express'; import { bindAccess } from './bind-access.js'; import { games } from './games.router.js'; import { requireSession } from './session.js'; const api = express.Router(); api.use(requireSession); api.use(bindAccess); api.use('/games', games);
Not executed — nothing here runs it

app.use is positional, so a route registered above the middleware that binds the subject never sees it and nothing fails. Mount the binding on the router, as api does above. The library cannot close a route somebody registered above bindAccess. The app’s own route table can, and the assertion over it is the app’s test.

Registration order is what mediates. 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, and nothing fails.

Building Baize’s matrix at module scope

policy(…).build() validates, deep-clones and deep-freezes the document, so build the matrix once and hold it at module scope. ShopSubject and Game are the app’s own types, and SHOP_MATRIX_VERSION is the document revision a client compares its copy against. The three permissions are the ones apps/shop-api authors:

// access.ts import { policy, type Action } from '@evanion/acl'; import type { Game, ShopSubject } from './types.js'; export const SHOP_MATRIX_VERSION = 'shop-api@1'; // `declare` and `reprice` sit outside the default CRUD vocabulary, so `game` // names its own. export const access = policy< ShopSubject, { game: Game }, { game: Action | 'declare' | 'reprice' } >({ version: SHOP_MATRIX_VERSION }) .for('game', (p) => p .allow('read', p.always) .allow( 'declare', p.contains('subject.roles', 'operator'), p.eq('object.shop', 'subject.shop'), ) .fields({ fields: ['availability'] }) .allow( 'reprice', p.contains('subject.roles', 'manager'), p.eq('object.shop', 'subject.shop'), ) .fields({ fields: ['availability', 'price'] }), ) .build();
Not executed — nothing here runs it

A query after the build is a function call over the frozen copy. A rebuild per request repeats the clone on every call for a document that did not change.

Binding the subject

The subject comes from the verified session and nowhere else. can authorizes the bag it is handed and cannot ask where the bag came from, so a subject read off a header or a body is the attacker’s claimed identity, faithfully authorized.

// bind-access.ts import type { RequestHandler } from 'express'; import { access } from './access.js'; declare module 'express-serve-static-core' { interface Locals { access: ReturnType<typeof access.authorize>; } } export const bindAccess: RequestHandler = (req, res, next) => { // req.user is what `requireSession` verified. Nothing from the request body, // the query string or a header reaches this call. res.locals.access = access.authorize(req.user, { now: new Date() }); next(); };
Not executed — nothing here runs it

authorize binds the subject, and optionally the clock. The middleware above passes both, so a handler asks can(kind, action, object) without restating either. The region below binds the subject alone:

import { , type } from '@evanion/acl'; type = { : string; : string[] }; // One policy, every object kind the app has. A `.for()` per kind, and one // bound handle answers for all of them. type = { : { : string }; : { : string } }; // `report` grants a verb outside the default CRUD set, so it names its own // vocabulary. `listing` omits one and takes `Action`. const = <, , { : | 'export' }>() .('report', () => .('read', .('subject.roles', 'bookseller')) .('export', .('subject.roles', 'owner')), ) .('listing', () => .('read', .('subject.roles', 'owner'))) .(); const = .({ : 'u1', : ['bookseller'] }); .('report', 'read').; // -> true .('report', 'export').; // -> 'no-rule-matched'

The middleware pins now at bind time. Every before/after window in the matrix then reads one instant for the whole request, and two handlers in the same request cannot land on opposite sides of a boundary that closed between them.

Deciding on a row

game.declare reads object.shop, so the decision follows the read. catalogue is the app’s own store of titles, keyed by urn:

// games.router.ts import express from 'express'; import { catalogue } from './catalogue.js'; export const games = express.Router(); games.get('/:urn', async (req, res) => { const game = await catalogue.find(req.params.urn); if (!game) return res.sendStatus(404); const decision = res.locals.access.can('game', 'read', game); if (!decision.allowed) return res.sendStatus(403); res.json(game); });
Not executed — nothing here runs it

A fetch before the decision is no leak as long as nothing leaves the handler. An insecure direct object reference (IDOR) is what the response gives away, and the query gives away nothing.

decision.allowed is the gate. A decision can come back unevaluable, meaning the projection did not carry a path some rule reads, and that is neither an error nor a yes. missing names the paths, so a thin projection is one refetch from an answer:

if (decision.reason === 'unevaluable') { // decision.missing is e.g. ['object.shop'] const full = await catalogue.find(req.params.urn, { fields: decision.missing, }); // decide again against the complete row }
Not executed — nothing here runs it

A gate on the reason string grants what the engine refused. allowed is the only gate.

Writes

A write decides on the field axis and writes what pickAllowedFields returns. req.body is the proposed write. The write axis decides every key of it, including keys the title has never held, which is what closes mass assignment:

import { pickAllowedFields } from '@evanion/acl'; games.patch('/:urn', async (req, res) => { const current = await catalogue.find(req.params.urn); if (!current) return res.sendStatus(404); const decision = res.locals.access.canFields( 'game', 'declare', current, 'write', req.body, ); if (!decision.action.allowed) return res.sendStatus(403); const writable = pickAllowedFields(decision, req.body); if (Object.keys(writable).length === 0) return res.sendStatus(400); await catalogue.update(current.urn, writable); res.sendStatus(204); });
Not executed — nothing here runs it

decision.action is the action-level decision that gates the field map. pickAllowedFields throws ActionNotAllowedError when the action was refused, so a handler that skips the check turns a refusal into a 500. A hand-written filter on !== 'denied' writes the unevaluable fields and every key the decision does not carry at all.

An empty writable is the third case: the action stands, the row is real, and the decision allowed no field of the body. apps/shop-api answers 400 there.

Refusals as one handler

One error-handling middleware catches a Forbidden the handler throws and answers 403 in one place. Decision is the shape can returns, and reason is the member naming why:

// forbidden.ts import type { Decision } from '@evanion/acl'; export class Forbidden extends Error { constructor(readonly decision: Decision) { super(decision.reason); } }
Not executed — nothing here runs it
// Four arguments, registered last: Express selects error middleware by arity. app.use((error, _req, res, next) => { if (!(error instanceof Forbidden)) return next(error); res.status(403).json({ reason: error.decision.reason }); });
Not executed — nothing here runs it

A decision names the rule that refused. On an API serving parties who should not learn each other’s privilege model, rule is more than the caller asked for. Send reason to a caller who may read it, and send neither member to one who may not.

Serving the matrix to a browser client

An Express API in front of a browser app can hand the app its matrix, which is the document and nothing else. serialize keeps only the permissions marked visibility: 'public', each one byte for byte:

import { serialize } from '@evanion/acl'; app.get('/policy', (req, res) => { const matrix = serialize(access, 'reduced'); res.json({ version: matrix.version, matrix }); });
Not executed — nothing here runs it

matrix is an envelope carrying its own version and schema, so nothing travels beside it. access itself does not serialize: the value is closures over the frozen document, and JSON.stringify drops every method in silence.

The client that receives this evaluates for itself. The client’s copy toggles what the user sees and enforces nothing. This handler and every one above it decides again on the next request, against the session it verifies itself, and never reads the client’s copy of anything.

Where to go next

  • Security contract — subject authenticity, complete mediation, and the rest of what the consumer owns
  • Field permissions — the two axes and the write path
  • NestJS — the same boundary, with a guard in front of it
Last updated on