Express
@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.
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.
| Piece | Who writes it | What it does |
|---|---|---|
access | you, once, at module scope | holds the rules, validated, deep-cloned and deep-frozen at build |
requireSession | your session layer | verifies the caller and sets res.locals.user |
bindAccess | you, in five lines | binds that user and one now with access.authorize |
| the route | you, once per route | loads 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.
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.allowedis checked first.pickAllowedFieldsthrowsActionNotAllowedErroron a refused action, and Express answers a thrown error with 500.- An empty
writablemeans 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 markedunevaluable, 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
- NestJS. The same boundary, with a guard in front of the route.
- Field permissions. The read axis and the write axis.
- How do I give my rules to another service?. What
serializekeeps, andvisibility. - Security contract. Subject authenticity, complete mediation, and the rest of what an app owns.