Skip to Content
AuthorizationReact Router 8

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.

React Router Integration

In a React Router 8 application, the boundary between the user and the server is split across two distinct entry points: the loader and the action. Because these run independently on the server, each must resolve the subject and make its own ACL decisions to ensure security.

In the Baize admin portal (apps/admin), @evanion/acl is used to gate page access via loaders and enforce data mutations via actions, while the browser-side UI simply toggles controls based on those same rules.

Adopting a Remote ACL Document

The admin portal does not author its own rules; instead, it “adopts” the policy document published by the shop-api. This allows the admin portal to share the same authorization logic as the backend service without duplicating the rules.

The adoptMatrix function transforms the published JSON document into a live @evanion/acl evaluator:

let adopted: | { version: string | number; access: Access<AdminSubject, AdminObjects> } | undefined; /** * Adopts a matrix that arrived over the wire. * * `parseMatrix` and not `hydratePolicy`: the document is foreign input, and the * closed mode is what makes an unknown permission a refusal rather than a throw * inside a loader. */ export function adoptMatrix( matrix: Matrix, ): Access<AdminSubject, AdminObjects> { const { version } = matrix; if (version !== undefined && adopted?.version === version) { return adopted.access; } const access = parseMatrix<AdminSubject, AdminObjects, AdminPermission>( matrix, ); if (version !== undefined) adopted = { version, access }; return access; }

To optimize performance, the document is memoized using a version key. Since parseMatrix performs deep-cloning and freezing on every call, memoization prevents unnecessary overhead when the same document version arrives again.

Because the server evaluates the matrix for enforcement and the browser evaluates it for UI toggles, access.ts is a shared module. However, the network call to fetch the document is isolated in access.server.ts, ensuring that the API credentials and fetch logic are kept out of the browser bundle.

The Loader and Action: Two Separate Boundaries

A common mistake in framework integration is assuming that a decision made during the page render (the loader) persists until the data is written (the action). In React Router, a <Form> submission or a direct curl request reaches the action without ever triggering the loader.

Therefore, every action must re-resolve the subject and re-evaluate the permission against the most recent version of the data.

PhaseRuntimeResponsibility
middlewareServerResolves the identity (Who is asking?)
loaderServerDecides if the subject may read the resource (Can they see this page?)
Route ComponentBrowserToggles UI elements based on the policy (Should I show this button?)
actionServerDecides if the subject may write the resource (Can they change this data?)
The dual-server boundary in React Router. Middleware identifies the user, the loader gates the read, and the action independently gates the write. The browser tree sits between them, merely reflecting the policy for a better user experience.

Resolving Identity in Middleware

Middleware is the ideal place to resolve the subject and bind it to the request context. By doing this in the middleware, every subsequent loader and action on the route can assume a consistent identity.

Using React Router 8’s middleware and createContext, the application binds the subject and the ACL evaluator to the request:

export const loadPageView: MiddlewareFunction<Response> = async ({ context, }) => { const correlationId = newCorrelationId(); context.set(correlationContext, correlationId); const subject = demoSubject(); context.set(subjectContext, subject); let contract: Promise<Awaited<ReturnType<typeof readAccess>>> | undefined; context.set( accessContext, () => (contract ??= readAccess(correlationId, subject)), ); let pending: Promise<ShelfSnapshot> | undefined; context.set( shelfContext, () => (pending ??= readShelf(correlationId, subject)), ); };

To ensure that a page view only triggers one network fetch for the policy document, a memoization cell is used. This ensures the fetch happens after any action has run, providing the freshest possible rules.

export const subjectContext = createContext<AdminSubject>({ id: 'anonymous', roles: [], shop: '', });

Security Note: Trusting the Subject

In this demo, demoSubject() simply reads an environment variable or defaults to a specific user. In a production system, the middleware must resolve the actor identity from a verified session or JWT. Since can authorizes whichever subject it is handed, the middleware is the ultimate trust boundary.

Gating Reads in the Loader

The loader decides if the bound subject has the necessary capabilities to view the page. For example, the orders page requires telemetry.read permissions, which are restricted to managers.

const subject = context.get(subjectContext); const access = await context.get(accessContext)(); if (!allows(access.capabilities(subject), 'telemetry.read')) { return { refused: true, unavailable: undefined, figures: [], items: defineLedgerItems([]), }; }

If the permission is refused, the loader returns a refused: true flag. The route component then narrows on this flag to display a “Not yours to read” panel instead of fetching and rendering the sensitive data.

Passing the Policy to the Browser

Because the Access evaluator contains functions, it cannot be serialized and passed from the server to the client. Instead, the loader returns the raw access.matrix (the JSON document).

The client-side shell then adopts this matrix using the same adoptMatrix logic used on the server. This allows the browser to use the useCan hook to toggle UI elements without making a network request for every check.

The PolicyProvider and ComposeProvider are used to make the policy available to the entire component tree:

export default function Shell({ loaderData }: Route.ComponentProps) { const { session, matrix, navItems, sidebarItems, now, unavailable, features, } = loaderData; // Rebuilt when the document's version changes and not per render: the parse // validates, deep-clones and deep-freezes. See `adoptMatrix`. const access = useMemo(() => adoptMatrix(matrix), [matrix]); const context = useMemo(() => ({ now }), [now]); return ( <ComposeProvider providers={[ provider(PolicyProvider, { access, subject: session.subject, context, }), // React Router types loader data through its serialization mapping, // which writes `unknown` members as `undefined`. The value is the set // the loader returned, so the mount restates the shipped type. provider(Flags, { features: features as ShippedFeatures, subject: session.subject, }), provider(SessionProvider, { session }), provider(ThemeProvider, { initialDensity: 'comfortable' }), CartProvider, ]} > <Chrome navItems={navItems} sidebarItems={sidebarItems} unavailable={unavailable} /> </ComposeProvider> ); }

Toggling the UI with useCan

The browser-side components use the useCan hook to decide whether to show specific controls. This is purely for user experience; it does not provide any actual security.

// A convenience, and the whole of what this decision does: it chooses between // the form and a sentence. `action` decides again and is what stops a // write. The shop slug is the only member the rule reads, so it is the only // one stated. const mayDeclare = useCan('game', 'declare', { shop: row.shop }).allowed;

For instance, an operator in Stockholm can see the “Declare” form for a title listed in their own shop, but they will see a different message for a title listed in Gothenburg.

Enforcing Writes in the Action

The action is the final trust boundary. It ignores any state from the loader and re-evaluates the permission against the actual data row retrieved from the database.

export async function action({ context, request, params }: Route.ActionArgs) { const subject = context.get(subjectContext); const correlationId = context.get(correlationContext); const access = await context.get(accessContext)(); const urn = params.urn; // The catalogue directly rather than through `shelfContext`, which joins the // catalogue to the availability table this action is about to write. Filling // that memo here would hand every revalidating loader the pre-write shelf. let games; try { games = await listGames(correlationId, subject); } catch (error) { if (!(error instanceof ShopApiUnavailable)) throw error; return { refused: true, error: `The catalogue is unreadable (${error.message}), so no decision can be reached and nothing was written.`, }; } const game = games.find((candidate) => candidate.urn === urn); if (!game) { throw new Response(`No title ${urn} on the shelf`, { status: 404 }); } const form = await request.formData(); const intent = form.get('intent'); if (intent === 'offer-pickup' || intent === 'withdraw-pickup') { return decidePickup( context, access, subject, game, intent === 'offer-pickup', ); } if (intent === 'clear') { const decision = access.can(subject, 'game', 'declare', game); if (!decision.allowed) return refusal(decision.reason); clearShelfPolicy(urn); return { declared: null }; } const state = form.get('availability'); if ( typeof state !== 'string' || !AVAILABILITY_STATES.includes(state as Availability) ) { return { error: `Not an availability state: ${String(state)}` }; } // The whole submitted bag reaches the write axis, which is the shape a form // submission has: every `name` attribute the browser was willing to send // arrives in it, including names this form never drew. `availability` is restated in the // catalogue's vocabulary, because that is what the matrix's `targets` list // holds. const proposed = { ...Object.fromEntries(form), availability: catalogueAvailability(state as Availability), }; const decision = access.canFields( subject, 'game', 'declare', game, 'write', proposed, ); if (!decision.action.allowed) return refusal(decision.action.reason); // Only what the decision marks allowed. Filtering the bag by hand on // `!== 'denied'` would write the unevaluable fields and every key the decision // does not carry, which is the mass-assignment shape a submitted form has. const writable = pickAllowedFields(decision, proposed); if (typeof writable['availability'] !== 'string') { return { refused: true, error: 'The contract does not allow this title’s availability to be written.', }; } setShelfPolicy(urn, state as Availability); return { declared: state as Availability }; } /** * What a refused write answers with. * * A returned union member the component narrows, not a thrown `Response`: a * merchant who submitted a title another shop lists has something to act on, and * the page around the form is still the page they want. A refusal meaning "you * should not have seen this control" is the thrown case, and the form is hidden * from those subjects already. */ function refusal(reason: string) { return { refused: true, error: `Refused: ${reason}. Availability is declared by the shop that lists the title.`, }; }

The action checks decision.action.allowed first. If the write is refused, it returns a failure response. By using pickAllowedFields, the action also ensures that only the fields the subject is permitted to modify are actually written to the database, regardless of what was sent in the formData.

Verifying the Action Independently

Since curl requests can bypass the browser UI, it is critical to test the action directly.

Using a RouterContextProvider, tests can simulate a request by calling the action function with a mock context that mimics the middleware’s output:

/** The request context the shell's middleware would have built. */ function contextFor(subject: AdminSubject): RouterContextProvider { const context = new RouterContextProvider(); context.set(correlationContext, 'admin-test'); context.set(subjectContext, subject); context.set(accessContext, async () => access); context.set(shelfContext, async () => ({ rows: [] })); return context; } /** A form submission at the availability form's URL, whoever sent it. */ function declaring(urn: string, fields: Record<string, string>): Request { const body = new FormData(); for (const [name, value] of Object.entries(fields)) body.append(name, value); return new Request(`http://admin.test/shelf/${urn}`, { method: 'POST', body, }); } /** * Calls the route's action the way the router would, and the way anything else * that reaches the URL does. * * The cast goes through `unknown` because the generated argument type carries * the route's match tree, which the action itself reads none of. What it does * read is stated in full: the request, the urn, and a context the middleware * would have filled. */ function callAction(subject: AdminSubject, urn: string, request: Request) { return action({ request, params: { urn }, context: contextFor(subject), } as unknown as Parameters<typeof action>[0]); }

Tests specifically cover the “disagreement” case: where the UI might have shown a button (due to a stale policy), but the server action refuses the write because the data changed or the policy was updated.

it('refuses a title another shop lists, and writes nothing', async () => { const result = await callAction( operator, GOTHENBURG_URN, declaring(GOTHENBURG_URN, { intent: 'declare', availability: 'out of print', }), ); expect(result).toMatchObject({ refused: true }); expect(readShelfPolicy()[GOTHENBURG_URN]).toBeUndefined(); });

Where to go next

Last updated on