Skip to Content
AuthorizationChoosing a framework integration

Choosing a framework integration

@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.

@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-api is the NestJS service that owns the catalogue and publishes its matrix.
  • apps/storefront (Astro), apps/storefront-rsc (Next.js) and apps/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:

PlatformThe call that decidesWhat is not a boundary
Expressthe request handlernothing crosses; the whole app is trusted
NestJSthe guard, then the service after the readthe guard alone, which runs before the row
Astro SSRthe page’s frontmatter, once per requestthe rendered markup, and the form posting back
Next.js (React Server Components)the server component, and each Server Action separatelythe client component, and the action’s caller
React Router 8the loader, and each action separatelythe 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.

Which rebuild call a fetched document gets. A document the same process authored rebuilds with hydratePolicy, where a key the document does not carry throws UnknownPermissionError; a document another service published rebuilds with parseMatrix in closed mode, where the same key answers unknown-action and the page keeps serving.

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 same permission decided twice, for two different questions. The browser copy decides what the page draws and enforces nothing; a script that never loads the page reaches the handler anyway, where the server copy decides whether the write happens.

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.

Last updated on