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.
Choosing a framework integration
@evanion/acl imports no framework, so every platform answers the same three
questions: where the matrix is built, where the subject comes from, and which
call decides.
Building the matrix once
The guides’ examples are regions of Baize’s demo apps in this repository:
apps/shop-apiis the NestJS service that owns the catalogue and publishes its matrix.apps/storefront(Astro),apps/storefront-rsc(Next.js) andapps/admin(React Router) fetch that matrix and decide on it.
hydratePolicy validates the document, deep-clones it and deep-freezes it, and
every decision after that is a local function call over the frozen copy. Build
the matrix once per process and hold it at module scope. apps/shop-api does
that at boot, over the document SHOP_MATRIX, its subject type ShopSubject
and the row map ShopObjects its builder produced. hydratePolicy returns an
Access, the evaluator every later decision calls:
export function buildShopAccess(): Access<ShopSubject, ShopObjects> {
return hydratePolicy<ShopSubject, ShopObjects>(SHOP_MATRIX);
}Holding a document you fetched
apps/storefront has no document of its own at boot, so it fetches shop-api’s
and holds it at module scope. HeldPolicy is the app’s own type, the adopted
Access and the version it came from, and ShopApi is its typed client for
shop-api. POLICY_REVALIDATE_MS is how long the app serves the held document
before it asks again:
export const POLICY_REVALIDATE_MS = 60_000;
let held: HeldPolicy | undefined;
let checkedAt = 0;
let inFlight: Promise<Access | undefined> | undefined;
/**
* The document this process decides on.
*
* Module scope and not per request: construction validates, deep-clones and
* deep-freezes the document, every decision after that is a local function call
* over the frozen copy, and the document is the same for every visitor. Holding
* it here is what keeps a network call out of a decision -- a page render reads
* an already-adopted `Access` and calls `can` on it.
*
* Two things replace what is held: a revision the shop-api reports that differs
* from the held one, and the {@link POLICY_REVALIDATE_MS} window expiring, which
* is what makes this app ask at all. A matching revision re-uses the adopted
* object, so re-validating the same bytes on a poll is skipped.
*
* A failed or unreadable response leaves the held document in place and the
* window open, so the next request asks again. The alternative refuses every
* shopper for as long as the shop-api is unreachable, while the shop-api is the
* layer that would refuse a real write anyway.
*
* No `fetchedAt` is reported to `parseMatrix`: that option obliges the document
* to state `maxStale`, and the shop-api's does not.
*/
export async function policyAccess(api: ShopApi): Promise<Access | undefined> {
if (held && Date.now() - checkedAt < POLICY_REVALIDATE_MS) return held.access;
// One fetch for however many renders are in flight when the window expires.
inFlight ??= refresh(api).finally(() => {
inFlight = undefined;
});
return inFlight;
}The window is the ceiling on how long a rule change takes to reach this app’s
gates. One minute is safe because shop-api re-decides every request on its own
copy. refresh adopts a new version with parseMatrix, the call for a
document another service published, and keeps the held document on every
failure:
async function refresh(api: ShopApi): Promise<Access | undefined> {
const document = await api.policy();
if (!document.ok) return held?.access;
const { version, matrix } = document.value;
if (held && held.version === version) {
checkedAt = Date.now();
return held.access;
}
try {
const access = parseMatrix(matrix);
held = { version, access };
checkedAt = Date.now();
return access;
} catch {
// A document that fails validation is not a document. Nothing replaces what
// is held, and the window stays open so the next request asks again.
return held?.access;
}
}An owner can state maxStale on its document, the longest a consumer may keep
deciding on a copy it fetched. A consumer that passes fetchedAt to
parseMatrix then gets stale-contract on every key once that time has passed.
shop-api’s document states no maxStale, and parseMatrix throws
MissingFreshnessBudgetError on a fetchedAt without one, so this app passes
none. How long a consumer may keep deciding
has the arithmetic.
Resolving the subject
can(subject, …) authorizes the bag it is handed and cannot ask where the bag
came from. Each guide resolves it from that platform’s own verified session
primitive, server-side, before the first decision. A subject assembled from a
header, a query parameter or a posted field is whatever the caller claims, and
no evaluator detects it.
Where the boundary sits
The five platform guides are wiring over one API. Every call they make is on the API reference, and what changes per platform is where the authoritative call sits and what leaves the trusted runtime.
Enforcement is server-side, and the browser copy toggles
In a browser, can toggles what the user sees. Real enforcement happens in
a trusted environment: a React Router 8 or Next.js server runtime, or the
server side of an API boundary. Every app in the chain does its own
evaluation and trusts no earlier layer. A page that hid the button does not
excuse the handler the button posts to. A gateway that allowed the request
does not excuse the service behind it. The security contract
is the long form.
Five platforms put the authoritative call in five places:
| Platform | The call that decides | What is not a boundary |
|---|---|---|
| Express | the request handler | nothing crosses; the whole app is trusted |
| NestJS | the guard, then the service after the read | the guard alone, which runs before the row |
| Astro SSR | the page’s frontmatter, once per request | the rendered markup, and the form posting back |
| Next.js (React Server Components) | the server component, and each Server Action separately | the client component, and the action’s caller |
| React Router 8 | the loader, and each action separately | the component tree, and clientLoader |
Express and NestJS are server-only and have one boundary, the request handler. Next.js and React Router have two: the render path and the write path, reached independently by a caller who never renders anything.
Astro SSR is the fifth shape, and server-side rendering (SSR) is
the whole of its trusted runtime. A prerendered Astro site has no runtime at
request time, so it belongs in no row of this table. A server-rendered one runs
a page’s frontmatter per request in a process the operator controls, and that
frontmatter is the loader and the action in one module. apps/storefront’s
cart.astro reaches one order.create decision, whether this visitor may place
an order, and its POST branch and its checkout button both read it.
Many services closes this band on a topology, where each service authors and evaluates only the objects it owns.
What crosses, on the platforms where something does
Access, the evaluator type .build() and hydratePolicy return, holds
functions over a frozen document, and functions do not serialize. structuredClone throws on the functions, and JSON.stringify
silently drops every method. The document is what crosses a server/client
boundary, and the client rebuilds the evaluator from it:
import { } from '@evanion/acl';
const = ({
: 'orders@7',
: [
{ : 'question.read', : 'question', : 'read', : [] },
],
});
// The server sends `access.matrix`; the client rebuilds from it.
const = .(
.(.),
) as typeof .;
().; // -> 'orders@7'access.matrix carries its own version and schema, so the payload needs no
separate version field. A payload of { matrix, version, subject } states the
version twice.
The subject crosses too, because the client needs it to evaluate. The subject is no credential: the server resolves its own from its own session on the next request and never reads the client’s copy.
Which function rebuilds depends on where the document came from
hydratePolicy rebuilds a document the same process authored, which is the
round trip above. parseMatrix rebuilds one that came from somewhere else, and
it is hydratePolicy in closed mode: a key the document does not carry answers
unknown-action, where hydratePolicy throws UnknownPermissionError.
When the owner removes a permission, parseMatrix refuses the action and keeps
serving, and hydratePolicy throws. apps/storefront, apps/storefront-rsc
and apps/admin fetch apps/shop-api’s matrix and adopt it closed.
Narrow a subject before you forward it
An app that forwards its subject to another service screens the roles first.
Astro SSR
shows apps/storefront doing it.
Deciding twice is the design
can is called twice on a platform with a browser half, once on the server and
once in the browser. Keep both. The browser copy answers “what should this page
look like”, for a person looking at it. The server copy answers “may this
happen”, for a script posting to the same URL with curl.
The write path
A permission with no .fields call decides the whole write with one can
call, as apps/storefront’s checkout does. A permission with .fields needs
canFields as well. Its 'write' argument asks which keys of the proposed body
the subject may set, and the value to write is what pickAllowedFields returns.
.fields(['*', '!status']) below allows every field but status:
import { , } from '@evanion/acl';
const = <
{ : string },
{ : { : string; : string; : string } }
>()
.('question', () =>
.('update', .('object.askedBy', 'subject.id'))
.(['*', '!status']),
)
.();
const = { : 'customer-41', : 'In stock?', : 'open' };
const = { : 'Wingspan in stock?', : 'locked' };
const = .(
{ : 'customer-41' },
'question',
'update',
,
'write',
,
);
..; // -> true
.['status']; // -> 'denied'
.((, )); // -> '{"body":"Wingspan in stock?"}'Check fd.action.allowed first. pickAllowedFields throws
ActionNotAllowedError on a refused action, so the check keeps the refusal off
the exception path. What the call returns is only the keys the decision marked
allowed. Field permissions has the rest.