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
cancall 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);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.
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();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();
};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);
});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
}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);
});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);
}
}// 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 });
});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 });
});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