Skip to Content
React AuthorizationWhich Side Decides

Unreleased changes

These pages document main, which has changes 0.1.2 does not. npm install @evanion/react-acl gives you 0.1.2, documented here. Values on these pages are produced by running the current source on every build.

Which side decides

To enforce a permission, call access.can from @evanion/acl on the server, where the page renders and again in the handler that writes. useCan runs in the shopper’s browser and only shows or hides controls.

What a react-acl app calls on the server

A server module builds its evaluator once, with policy from @evanion/acl, and imports nothing from @evanion/react-acl. The libraries repository keeps it at libs/react-acl/examples/server.tsx, outside the published package. ListingPage asks 'read' before it renders anything, and editListing, the handler behind the Edit button, asks 'edit' before it writes:

import { } from '@evanion/acl'; type = { : string; : 'customer' | 'bookseller' | 'owner' }; type = { : string; : string; : 'draft' | 'published' }; // Built once per server process. export const = < , { : }, { : 'read' | 'edit' } >() .('listing', () => .('read', .) .('edit', .('object.sellerId', 'subject.id')) .('edit', .('object.status', 'published')), ) .(); // A server component. The loader of a React Router route asks the same way. export function ({ , , }: { : ; : ; }) { const = .(, 'listing', 'read', ); if (!.) return <>No such listing.</>; return <>{.}</>; } // The handler behind the Edit button. The caller passes the shopper its // session resolved and the listing it read from the database, never either one // from the posted form. export function ( : , : , : string, ): Response { const = .(, 'listing', 'edit', ); if (!.) return new (null, { : 403 }); return .({ ..., }); }

The policy uses parts of @evanion/acl that acl’s own Getting started page does not show:

  • The third type argument, which declares each kind’s actions as in react-acl’s Getting started, now lists 'read' | 'edit'.
  • p.always is a condition that always holds, so everybody may read a listing.
  • .deny refuses where its condition holds, and a refusal wins over any allow, so nobody edits a published listing.

editListing refuses Jo, who did not list the game, and refuses Mika once the listing is published, whatever the browser drew:

import { editListing } from '../examples/server'; const mika = { id: 'mika', role: 'bookseller' } as const; const jo = { id: 'jo', role: 'owner' } as const; const draft = { id: 'brass-birmingham', sellerId: 'mika', status: 'draft', } as const; const published = { ...draft, status: 'published' } as const; editListing(mika, draft, 'Unpunched, still in shrink').status; // -> 200 editListing(jo, draft, 'Unpunched, still in shrink').status; // -> 403 editListing(mika, published, 'Unpunched, still in shrink').status; // -> 403

Each // -> comment is the HTTP status its line returns. The server hands access.matrix to ShopAccess, the client component Getting started mounts, so the browser evaluates the same rules.

Where each layer of a react-acl app enforces

react-acl’s useCan('listing', 'edit', listing) in the browser and access.can(shopper, 'listing', 'edit', listing) on the server return the same Decision over the same rules, and neither call’s type says which one enforces. Where each call runs separates them. The server’s call runs where the shopper cannot change the code, the rules or the subject. The browser’s call runs over a matrix the shopper can read, against a subject the shopper’s own page supplied.

WhereRuns inEnforces?
Middleware, the code a framework runs before a routeserveryes, for what it lets through
Loader or server componentserveryes, for what it returns or renders
PolicyProvider and its treebrowsernever; it toggles what the shopper sees
Action, Server Action, route handlerserveryes, independently, for what it writes

A caller reaches an action, the last row, directly. curl posts to the action without rendering anything, so the handler resolves the subject from the session, reads the listing from the database, and decides again. Every layer runs its own evaluation and trusts no earlier one. If Baize splits its shop into a storefront service and an order service, the order service decides again on every call from the storefront, for the same reason.

access.can is synchronous and reads a matrix the server already holds in memory, so no decision in the server rows waits on the network.

Why react-acl cannot run in a server component

@evanion/react-acl needs createContext and useContext. A React Server Component (RSC) renders only on the server, against a build of React that exports neither, so react-acl’s entry point carries 'use client'. An RSC that imports useCan receives a reference to client code in place of the function, and calling it during the server render throws.

An RSC renders a client component such as ShopAccess instead, and every prop it passes is serialized on the way to the browser. For its own decisions an RSC calls access.can from @evanion/acl directly.

What crosses the render in a react-acl app

In a react-acl app the server’s evaluator is an Access from @evanion/acl: a set of closures over a frozen document. JSON.stringify drops every method on it, so no loader return value and no server component prop can carry one. The matrix crosses in its place, and ShopAccess calls hydratePolicy on it to build the browser’s own evaluator.

What crossesThe form it crosses inWhat the browser does with it
Accessnothing; closures do not serializerebuilds its own with hydratePolicy
the matrixone JSON document, access.matrixhydrates it into an evaluator
the subjecta plain object, and no credentialevaluates against it
nowan ISO stringreads every time window against it

The subject crosses because the browser needs it to evaluate. Nothing reads it back: on the next request the server resolves its own subject from the session.

now crosses as the string Getting started resolves on the server, so no time window in the policy is read against a clock the shopper’s machine sets.

How fresh the browser’s copy of a react-acl matrix is

The matrix a react-acl provider holds is as old as the render that delivered it, so a permission the server revokes keeps granting in the browser until a new matrix replaces the copy. @evanion/acl can expire the copy:

  • The server adds maxStale, a lifetime in milliseconds, to the matrix it sends: { ...access.matrix, maxStale: 300_000 } lasts five minutes.
  • The browser passes fetchedAt, the instant it fetched the matrix, to the hydratePolicy call in ShopAccess: hydratePolicy(matrix, { fetchedAt }). For a matrix that arrived with the server render, fetchedAt is the now the server sent with it.

Once the instant a decision reads is more than maxStale milliseconds past fetchedAt, every decision answers allowed: false with reason: 'stale-contract', acl’s reason for an expired matrix, and every control gated on it hides until a refetch replaces the matrix. A react-acl decision reads the provider’s context.now, and ShopAccess passes the server’s render instant, which never moves. With ShopAccess as Getting started builds it, maxStale never fires, and only a refetch, which brings a new matrix and a new server now, replaces the copy.

maxStale matters to a react-acl client that keeps deciding between fetches. That client has to pass a now it reads from its own clock, and the clock the shopper’s machine sets then also decides every time window in the policy. Refetch on the cadence your revocations need.

Follow the react-acl boundary into your framework

Two framework guides carry the react-acl boundary through a whole app. React Router 8 puts these layers in a middleware, a loader, an action and the provider in the shell. Next.js puts them in a server component, a client boundary and the Server Action behind the button. acl’s publishing guide covers maxStale in full, and the security contract covers the rest of the trust boundary.

Last updated on