@evanion/acl
v0.1.0
Published
A declarative, serializable access-control matrix authored once and evaluated locally on whatever JS runtime is running: a Node backend, a frontend SSR graph, a browser SPA, or a hybrid JS platform.
Maintainers
Readme
Authorization
A declarative, serializable access-control matrix authored once and evaluated locally on whatever JS runtime is running — a Node backend, a frontend SSR graph, a browser SPA, or a hybrid JS platform (React Native, Electron). The matrix is a frozen object that round-trips through JSON. No per-subject snapshots, no backend roundtrip.
The matrix ships to the client in full and evaluates locally, so it cannot contain secrets or server-side-only predicates. Opaque checks live outside the matrix, as server-side app-layer decisions. What that costs, and what each consumer owns, is the security contract.
Installation
npm install @evanion/aclESM only. Node 20 or newer. Nothing else enters the import graph.
The first policy
The smallest policy that answers a question, and the two answers it gives. Baize, a board game shop, lets the customer who asked a question edit it and nobody else:
import { policy } from '@evanion/acl';
type Question = { id: string; askedBy: string };
const access = policy<{ id: string }, { question: Question }>()
.for('question', (p) =>
p.allow('update', p.eq('object.askedBy', 'subject.id')),
)
.build();
const asker = { id: 'customer-41' };
const question = { id: 'q7', askedBy: 'customer-41' };
access.can(asker, 'question', 'update', question).allowed; // -> true
const stranger = { id: 'customer-92' };
access.can(stranger, 'question', 'update', question).allowed; // -> false
access.can(stranger, 'question', 'update', question).reason; // -> 'no-rule-matched'allowed is the answer and reason says which branch of the engine produced
it. no-rule-matched is the default deny: nothing in the document granted the
stranger the action, so the engine refused without any rule saying to.
Quick start
Three entry points, split by where the document came from:
| Entry | The document is | An unknown key |
| ---------------- | ------------------------- | -------------- |
| policy().build | one you are writing now | throws |
| hydratePolicy | yours, arriving back | throws |
| parseMatrix | somebody else's, arriving | fails closed |
policy() is where a policy is written. hydratePolicy takes a document that
already exists and returns an evaluator over it, which is what a server's
matrix reaching a client is. parseMatrix is the same call with closed: true
preset, because a document whose author you are not should refuse an unknown
key rather than throw.
import { policy } from '@evanion/acl';
type Question = { id: string; askedBy: string };
const access = policy<{ id: string }, { question: Question }>()
.for('question', (p) =>
p.allow('update', p.eq('object.askedBy', 'subject.id')),
)
.build();
const decision = access.can({ id: 's1' }, 'question', 'update', {
askedBy: 's1',
});
decision.allowed; // -> trueA decision explains itself
allowed is the answer. reason, rule and missing are the explanation,
and they are output only — nothing in the library reads a reason back to
decide anything.
Default deny, an allow rule grants, a matched deny outranks a matching allow, and a deny the engine could not read refuses too:
import { policy } from '@evanion/acl';
type Question = { askedBy: string; status: string };
const access = policy<{ id: string }, { question: Question }>()
.for('question', (p) =>
p
.allow('update', p.eq('object.askedBy', 'subject.id'))
.deny('update', p.eq('object.status', 'locked')),
)
.build();
const subject = { id: 's1' };
// An allow rule matched, and no deny did.
const mine = access.can(subject, 'question', 'update', {
askedBy: 's1',
status: 'draft',
});
mine.reason; // -> 'allow'
// Somebody else's question: no allow rule matched.
const theirs = access.can(subject, 'question', 'update', {
askedBy: 's2',
status: 'draft',
});
theirs.reason; // -> 'no-rule-matched'
// A matched deny outranks the allow that also matched.
const locked = access.can(subject, 'question', 'update', {
askedBy: 's1',
status: 'locked',
});
locked.reason; // -> 'denied'
// A projection carrying neither field. The deny side could not be read, so the
// permission is not answerable yet — and the answer names what to fetch.
const partial = access.can(subject, 'question', 'update', {});
partial.allowed; // -> false
partial.reason; // -> 'unevaluable'
partial.missing; // -> ['object.status', 'object.askedBy']unevaluable is a third state, not an error and not a no: the object the caller
passed did not carry a path some rule reads. missing names those paths, so one
refetch settles the permission. An absent object.* path is unevaluable for
every operator, negative ones included; an absent subject.* path is an
ordinary miss, because the app resolves the subject whole and never projects it.
Repairing one
missing is a shopping list. Fetch what it names, ask again, and the permission
settles. Both sides' unreadable paths come back together, so one refetch is
enough however many rules read the object.
import { policy } from '@evanion/acl';
type Question = { id: string; askedBy: string };
const access = policy<{ id: string }, { question: Question }>()
.for('question', (p) =>
p.allow('update', p.eq('object.askedBy', 'subject.id')),
)
.build();
// The list query selected `id` and `body`; the rule reads `askedBy`.
const projection = { id: 'q1', body: 'Does this ship sleeved?' };
const first = access.can({ id: 's1' }, 'question', 'update', projection);
first.reason; // -> 'unevaluable'
first.missing; // -> ['object.askedBy']
// Fetch exactly what `missing` names, then ask once more.
const complete = { ...projection, askedBy: 's1' };
access.can({ id: 's1' }, 'question', 'update', complete).allowed; // -> trueA permission that stays unevaluable after a refetch is a bug in the document,
almost always a mistyped field name. A schema turns that class into an
UnknownFieldError at construction.
Explaining one
A screen that refuses reads reason to say why. The handler behind it still
gates on allowed alone, so the label is the only thing the reason decides:
import { policy } from '@evanion/acl';
import type { Decision } from '@evanion/acl';
type Question = { askedBy: string; status: string };
const access = policy<{ id: string }, { question: Question }>()
.for('question', (p) =>
p
.allow('update', p.eq('object.askedBy', 'subject.id'))
.deny('update', p.eq('object.status', 'locked'))
.id('locked-question'),
)
.build();
/** What the Edit button under a question says. */
function editLabel(decision: Decision): string {
if (decision.allowed) return 'Edit';
if (decision.reason === 'denied') return `Locked (${decision.rule})`;
if (decision.reason === 'unevaluable') return 'Checking';
return 'Not your question';
}
const asker = { id: 'customer-41' };
const ask = (row: Partial<Question>) =>
access.can(asker, 'question', 'update', row);
editLabel(ask({ askedBy: 'customer-41', status: 'open' })); // -> 'Edit'
editLabel(ask({ askedBy: 'customer-41', status: 'locked' })); // -> 'Locked (locked-question)'
editLabel(ask({ askedBy: 'customer-92', status: 'open' })); // -> 'Not your question'
// The list query did not select `status`, so the deny rule could not be read.
const partial = ask({ askedBy: 'customer-41' });
editLabel(partial); // -> 'Checking'
partial.rule; // -> 'locked-question'
partial.missing; // -> ['object.status']Asking more than one question
canMany takes a list of instances and answers per instance. It settles the
clock once and resolves the object kind once, where a loop of can pays both
per row.
import { policy } from '@evanion/acl';
type Question = { id: string; askedBy: string };
const access = policy<{ id: string }, { question: Question }>()
.for('question', (p) =>
p.allow('update', p.eq('object.askedBy', 'subject.id')),
)
.build();
const rows = [
{ id: 'q1', askedBy: 's1' },
{ id: 'q2', askedBy: 's2' },
{ id: 'q3' }, // the projection this row came back in lacks the field
];
const decisions = access.canMany({ id: 's1' }, 'question', 'update', rows);
const reasons = decisions.map((decision) => decision.reason);
reasons; // -> ['allow', 'no-rule-matched', 'unevaluable']The array is parallel to the input, so the decision for rows[i] is
decisions[i]. Nothing is filtered out: a refused row still has an entry, which
is what lets a list render the refusal beside the row rather than dropping it.
A deny rule needs no object. It reads the subject the same way an allow rule does, and a policy whose every condition reads the subject still produces three of the four reasons.
import { policy } from '@evanion/acl';
type Shopper = { id: string; roles: string[] };
// Baize suspends an account without editing anybody's roles. The deny sits
// beside the allow it overrides, in the permission both belong to, and `.id()`
// names each rule so a decision can report which one decided.
const access = policy<Shopper, { report: { id: string } }>()
.for('report', (p) =>
p
.allow('read', p.contains('subject.roles', 'bookseller'))
.id('booksellers-read')
.deny('read', p.contains('subject.roles', 'suspended'))
.id('suspended-reads-nothing'),
)
.build();
const bookseller = { id: 'u1', roles: ['bookseller'] };
const suspended = { id: 'u2', roles: ['bookseller', 'suspended'] };
const customer = { id: 'u3', roles: [] };
access.can(bookseller, 'report', 'read').reason; // -> 'allow'
access.can(suspended, 'report', 'read').reason; // -> 'denied'
access.can(suspended, 'report', 'read').rule; // -> 'suspended-reads-nothing'
access.can(customer, 'report', 'read').reason; // -> 'no-rule-matched'The suspended bookseller satisfies the allow rule, and the deny settles first, so
the decision is denied. Nothing here can answer unevaluable: the app resolves
the subject whole before the call, so every condition has data to read.
capabilities asks the other way — no instance, every permission in the
document, resolved in document order against one subject.
import { policy } from '@evanion/acl';
import type { Action } from '@evanion/acl';
type Shopper = { id: string; roles: string[] };
// One policy, every object kind the app has. A `.for()` per kind, and the
// permissions they flatten to live in the same flat list.
const access = policy<
Shopper,
{ report: { id: string }; listing: { id: string } },
{ report: Action | 'export' }
>()
.for('report', (p) =>
p
.allow('read', p.contains('subject.roles', 'bookseller'))
.allow('export', p.contains('subject.roles', 'owner')),
)
.for('listing', (p) => p.allow('read', p.contains('subject.roles', 'owner')))
.build();
const caps = access.capabilities({ id: 'u1', roles: ['bookseller'] });
Object.keys(caps); // -> ['report.read', 'report.export', 'listing.read']
caps['report.read']?.allowed; // -> true
caps['report.export']?.reason; // -> 'no-rule-matched'
caps['listing.read']?.reason; // -> 'no-rule-matched'capabilities passes no object, so a permission whose rules need object.* to
settle decides unevaluable rather than true or false, with missing
naming the paths. A rule whose subject conditions already fail settles without
the row, and a permission reading only the subject always settles:
import { policy } from '@evanion/acl';
import type { Action } from '@evanion/acl';
type Shopper = { id: string; roles: string[] };
type Question = { askedBy: string; status: string };
const access = policy<
Shopper,
{ question: Question },
{ question: Action | 'hide' }
>()
.for('question', (p) =>
p
.allow('read', p.always)
.allow('update', p.eq('object.askedBy', 'subject.id'))
.allow(
'hide',
p.contains('subject.roles', 'bookseller'),
p.eq('object.status', 'open'),
),
)
.build();
const customer = { id: 'customer-41', roles: [] };
const caps = access.capabilities(customer);
// Reads only the subject, so it settles.
caps['question.read'].reason; // -> 'allow'
// Needs the row to settle, and names what it would read.
caps['question.update'].reason; // -> 'unevaluable'
caps['question.update'].missing; // -> ['object.askedBy']
// The customer holds no `bookseller` role, so the row cannot change the answer.
caps['question.hide'].reason; // -> 'no-rule-matched'A navigation menu filters its items against the map. A key the document does
not carry is absent from it, so the filter compares with === true:
import { parseMatrix, policy } from '@evanion/acl';
type Shopper = { id: string; roles: string[] };
// `stock` writes the report rules and serves the document as JSON. It declares
// nothing about listings.
const stock = policy<Shopper, { report: { id: string } }>()
.for('report', (p) =>
p.allow('read', p.contains('subject.roles', 'bookseller')),
)
.build();
const served = JSON.stringify(stock.matrix);
const access = parseMatrix(JSON.parse(served));
const caps = access.capabilities({ id: 'u1', roles: ['bookseller'] });
const menu = [
{ href: '/reports', label: 'Reports', key: 'report.read' },
{ href: '/listings', label: 'Listings', key: 'listing.read' },
];
const shown = menu.filter((item) => caps[item.key]?.allowed === true);
shown.map((item) => item.label); // -> ['Reports']
// Hiding only what the map refuses shows the key the document never declared.
const leaky = menu.filter((item) => caps[item.key]?.allowed !== false);
leaky.map((item) => item.label); // -> ['Reports', 'Listings']One policy behind a screen
A rendered interface is a set of controls, and each control is one can. The
policy is the only place a rule is written; the interface reads answers and
draws.
import { parseMatrix, policy } from '@evanion/acl';
/** Who is signed in. `role` is what the policy reads. */
interface Shopper {
role: 'customer' | 'bookseller' | 'owner';
}
/** A game listing in the shop. A status, because that is all a rule reads. */
interface Listing {
status: 'draft' | 'published';
}
// `stock` writes the listing rules and serves `stock.matrix` as JSON.
const stock = policy<
Shopper,
{ listing: Listing },
{ listing: 'review' | 'edit' | 'publish' }
>()
.for('listing', (p) =>
p
.allow('review', p.always)
.allow('edit', p.in('subject.role', ['bookseller', 'owner']))
.allow('publish', p.in('subject.role', ['owner']))
.deny('edit', p.eq('object.status', 'published')),
)
.build();
// The storefront adopts the document it fetched, and the action bar asks it.
const access = parseMatrix<Shopper, { listing: Listing }>(stock.matrix);
const bookseller: Shopper = { role: 'bookseller' };
const draft: Listing = { status: 'draft' };
access.can(bookseller, 'listing', 'review', draft).allowed; // -> true
access.can(bookseller, 'listing', 'edit', draft).allowed; // -> true
access.can(bookseller, 'listing', 'publish', draft).reason; // -> 'no-rule-matched'
access.can(bookseller, 'listing', 'edit', { status: 'published' }).reason; // -> 'denied'
access.can(bookseller, 'listing', 'edit', {}).reason; // -> 'unevaluable'
access.can(bookseller, 'listing', 'archive', draft).reason; // -> 'unknown-action'Four rules decide three controls for every role the shop has. stock writes
them and the storefront adopts the document with parseMatrix, so an action the
document does not carry answers unknown-action and a listing without status
answers unevaluable for edit. Publishing a listing changes the fourth answer
without anything else moving: the deny reads object.status, so the same
bookseller who could edit the draft cannot edit the listing once it is
published.
The matrix document
A matrix is an envelope, never a bare array:
{ "version": 3, "schema": { "objects": {} }, "permissions": [] }version and schema belong to the document, so a producer in any language
states both in the JSON it emits, and one value crosses an SSR boundary with
nothing assembled around it:
import { hydratePolicy } from '@evanion/acl';
const access = hydratePolicy({
version: 'orders@7',
permissions: [
{ key: 'question.read', object: 'question', action: 'read', rules: [] },
],
});
// The server sends `access.matrix`; the client rebuilds from it.
const payload = JSON.parse(
JSON.stringify(access.matrix),
) as typeof access.matrix;
hydratePolicy(payload).version; // -> 'orders@7'A construction site that changed the document states the version it runs:
import { hydratePolicy } from '@evanion/acl';
const shipped = hydratePolicy({
version: 'orders@7',
permissions: [
{
key: 'orders:order.refund',
object: 'orders:order',
action: 'refund',
rules: [],
},
],
}).matrix;
// This process merged deny overlay 41 into `orders@7` before construction, so
// it runs a revision `orders` never shipped. The option names that revision,
// and the frozen `matrix` carries it.
const effective = hydratePolicy(shipped, { version: 'orders@7+veto@41' });
effective.version; // -> 'orders@7+veto@41'
effective.matrix.version; // -> 'orders@7+veto@41'version is a string or a number. The revalidate contract compares it with
!==, so a content digest or a composite (orders@7+veto@41) works where a
number cannot — and a composite is what an effective version needs when a
construction site merges a document with something else, such as a compliance
deny overlay applied before construction.
hydratePolicy(matrix, { version }) overrides the document's value. The
document states what a producer shipped; the option states what the construction
site is actually running, which the producer cannot know. The option wins, and
the frozen access.matrix carries the winner, so the version that decided is the
version that crosses.
The schema
A document may declare the shapes its conditions read. The schema is optional for a producer and binding when present.
import { hydratePolicy } from '@evanion/acl';
import type { Matrix } from '@evanion/acl';
const matrix: Matrix = {
schema: {
subject: { fields: { id: 'string' } },
objects: {
question: { fields: { askedBy: 'string', status: 'string' } },
},
},
permissions: [
{
key: 'question.update',
object: 'question',
action: 'update',
// `status` is a string, and contains tests an array.
rules: [
{ when: [{ field: 'object.status', op: 'contains', value: 'draft' }] },
],
},
],
};
let refused = '';
try {
hydratePolicy(matrix);
} catch (error) {
refused = (error as Error).name;
}
refused; // -> 'FieldTypeMismatchError'Field types are flat strings, so the whole schema is JSON a producer emits by
reflection: string, number, boolean, instant, a [] suffix for an array
of one, a ? suffix for a field that may be absent from a complete instance. A
declared field that is present and null is present — nullability is not an
optionality axis.
What a present schema checks
Two things, both at construction, both an AclConfigError:
- A condition naming a field the schema does not declare
(
UnknownFieldError). Without a schema this is the typo class that decidesunevaluableforever on the foreign path. - A condition whose operator or comparand does not fit the declared type
(
FieldTypeMismatchError):containsagainst a field that is not an array, an equality against an array field, a literal of the wrong type, or apathcomparand whose two sides disagree.
What it does not check
- Object kinds it does not declare. Granularity is per kind: a permission on
a kind absent from
schema.objectsis unchecked, andsubject.*paths are unchecked unlessschema.subjectis declared. - Field-rule names. The
fieldsallow-list and thetargets/transitionskeys ofFieldRulesare not checked against the schema. - Whether a permission can decide
unevaluable. Proving that a permission naming no optional field always decides for a complete instance is not done; the?suffix is carried in the document and read by nothing today. instantfields, temporally.beforeandafterread the clock and nothing else, so aninstantfield is compared witheq,ne,inornot-inonly.
Typed authoring
policy<Subject, Objects, Verbs>() names the subject, the object kinds and the
actions each kind answers for, then returns a builder. Each .for() names its
kind as a value and hands the condition helpers to a block, so a typo in an
object.* or subject.* path is a compile error, and so is an unknown object
kind, an action outside the kind's vocabulary, or an object of the wrong kind at
the call site.
Verbs is optional, and a kind it does not name may declare the four
Action verbs. The keys capabilities() answers under are read off the
allow and deny calls each block actually made.
import { policy } from '@evanion/acl';
import type { Action } from '@evanion/acl';
type Subject = { id: string; roles: string[] };
type Question = { askedBy: string; status: 'open' | 'locked' };
type Listing = { sellerId: string };
// One policy, every object kind the shop has. Each `.for()` adds a kind and
// keeps the ones before it, so `access` answers for questions and listings
// alike and there is one document to ship.
const access = policy<
Subject,
{ question: Question; listing: Listing },
{ question: Action | 'hide' }
>()
.for('question', (p) =>
p
.allow(
'update',
p.or(
p.eq('object.askedBy', 'subject.id'),
p.contains('subject.roles', 'bookseller'),
),
)
.allow('hide', p.contains('subject.roles', 'bookseller'))
.deny('delete', p.eq('object.status', 'locked')),
)
.for('listing', (p) =>
p.allow('update', p.eq('object.sellerId', 'subject.id')),
)
.build();
const subject = { id: 's1', roles: [] };
const question = { askedBy: 's1', status: 'open' } as const;
access.can(subject, 'question', 'update', question).allowed; // -> true
access.can(subject, 'listing', 'update', { sellerId: 's1' }).allowed; // -> true
access.can(subject, 'listing', 'update', { sellerId: 's2' }).allowed; // -> falseThe block parameter carries the whole permission model: allow and deny
declare an action's rules, fields and visibility attach to the action most
recently declared in the chain, and the condition helpers are eq, ne, in,
notIn, contains, before, after, and, or and always.
An operand is read as a path when its type matches
`subject.${string}` | `object.${string}` | 'now', and as a literal value
otherwise. That shape is the only discriminator, and it is what makes
p.eq('object.status', 'published') a comparison against a literal while
p.eq('object.askedBy', 'subject.idd') is a compile error naming the path.
Action names and comparand value types are not checked at compile time. An
action the matrix does not carry is a legitimate question with a
unknown-action answer, so nothing refuses it.
One permission never gates another. An author who means "publish requires
update" writes update's condition into publish, where a reader of publish sees
it. Cond is a plain value, so the shared half is a local const named once
in the same block:
const access = policy<
Subject,
{ question: Question },
{ question: Action | 'hide' }
>()
.for('question', (p) => {
const isAsker = p.eq('object.askedBy', 'subject.id');
return p
.allow('update', isAsker)
.allow('hide', p.and(p.contains('subject.roles', 'bookseller'), isAsker));
})
.build();build() is the terminal. It flattens the blocks to the canonical matrix and
hands back an Access carrying the subject type and the key -> object-type map,
so every query checks its key and its object against what the blocks declared.
.matrix is the document on its own, for a caller that wants to overlay or ship
it. JSON.stringify(access.matrix) emits what a foreign backend would produce.
Naming a rule
.id(name) names the rule the previous allow() or deny() wrote. A decision
reports that name as rule, and diffMatrix reads it as the rule's identity
across an edit. A rule that states no name gets one derived from its conditions
and its side, and a release that changes how a condition is represented changes
every derived id, so an audit row holding allow-b72dadff stops matching its
rule and nothing reports that it has.
import { expect } from 'vitest';
import { policy } from '@evanion/acl';
import { AmbiguousRuleIdError, DuplicateRuleIdError } from '@evanion/acl';
type Subject = { id: string; roles: string[] };
type Listing = { sellerId: string; status: string };
type Objects = { listing: Listing };
const access = policy<Subject, Objects>()
.for('listing', (p) =>
p
.allow('update', p.eq('object.sellerId', 'subject.id'))
.id('seller-edits-own')
.deny('update', p.eq('object.status', 'withdrawn'))
.id('withdrawn-is-final'),
)
.build();
const seller = { id: 's1', roles: [] };
const listing = { sellerId: 's1', status: 'withdrawn' };
access.can(seller, 'listing', 'update', listing).rule; // -> 'withdrawn-is-final'
// An or() inside one allow() emits one rule per branch, and a name cannot
// name two of them.
expect(() =>
policy<Subject, Objects>().for('listing', (p) =>
p
.allow(
'update',
p.or(
p.eq('object.sellerId', 'subject.id'),
p.contains('subject.roles', 'bookseller'),
),
)
.id('two-branches'),
),
).toThrow(AmbiguousRuleIdError);
// One name, one rule, across both sides of the permission.
expect(() =>
policy<Subject, Objects>()
.for('listing', (p) =>
p
.allow('update', p.eq('object.sellerId', 'subject.id'))
.id('seller-edits-own')
.deny('update', p.eq('object.status', 'withdrawn'))
.id('seller-edits-own'),
)
.build(),
).toThrow(DuplicateRuleIdError);AmbiguousRuleIdError names how many rules the call wrote, and an author who
wants a name per branch writes one allow() per branch. .id() refuses before
any allow() or deny(), and after allowEach() or denyEach(), where
fields() and visibility() refuse for the same reason.
DuplicateRuleIdError refuses a second rule carrying a name already taken in
that permission. The allow side and the deny side share one name space: a
derived id carries the side it sits on, and a decision reports rule with no
side next to it, so one name on both sides leaves a support answer pointing at
two rules that decide opposite ways.
The document a typed policy emits
version and schema are document fields, so policy() takes them and puts
them in the document it flattens to rather than holding them beside it:
import { policy } from '@evanion/acl';
type Question = { askedBy: string; status: string };
const access = policy<{ id: string }, { question: Question }>({
version: 'orders@7',
schema: {
subject: { fields: { id: 'string' } },
objects: {
question: { fields: { askedBy: 'string', status: 'string' } },
},
},
})
.for('question', (p) =>
p.allow('update', p.eq('object.askedBy', 'subject.id')),
)
.build();
JSON.parse(JSON.stringify(access.matrix)).version; // -> 'orders@7'They sit on policy() rather than on a method at the end of the chain because
neither is a per-kind fact: .for() exists to write one kind's rules, and a
version and a schema are known before the first block is written.
A schema is written by hand. policy<Subject, { question: Question }>() holds
Question at the type level only, and a schema is runtime JSON, so nothing can
derive one from the type argument.
That makes the field-existence guarantee available twice on the typed path, and
the duplication is the point: TypeScript gives it to the author, and the schema
gives it to everyone downstream. The document travels; the types do not. A
consumer that adopts the emitted JSON with parseMatrix has no Question to
check against and gets the same guarantee from the schema. A typed author who
ships a document to nobody needs no schema.
The two are checked independently and can disagree. A path TypeScript accepts
because the type declares the field is still an UnknownFieldError at
construction when the schema does not declare it — the schema is binding
wherever it is present.
Marking a typed permission for publication
p.visibility('public') marks the action most recently declared in the chain,
the way p.fields() attaches to it. The emitted permission carries the same
visibility field a hand-written document carries, so
serialize(access, 'reduced') reads a builder-authored matrix and a foreign one
the same way.
import { policy, parseMatrix, serialize } from '@evanion/acl';
type Subject = { id: string; roles: string[]; tier: string };
type Order = { ownerId: string };
type Ledger = { period: string };
const orders = policy<
Subject,
{ 'orders:order': Order; 'orders:ledger': Ledger },
{ 'orders:order': 'refund'; 'orders:ledger': 'reconcile' }
>({ version: 'orders@7' })
.for('orders:order', (p) =>
p
.allow('refund', p.contains('subject.roles', 'bookseller'))
.deny('refund', p.eq('subject.tier', 'probation'))
.visibility('public'),
)
.for('orders:ledger', (p) =>
p.allow('reconcile', p.contains('subject.roles', 'accountant')),
)
.build();
const contract = serialize(orders, 'reduced');
contract.permissions.map((p) => p.key); // -> ['orders:order.refund']
const storefront = parseMatrix<Subject, { 'orders:order': Order }>(contract);
const bookseller = { id: 'u1', roles: ['bookseller'], tier: 'permanent' };
storefront.can(bookseller, 'orders:order', 'refund').allowed; // -> trueAn action nobody marks emits no visibility at all, which every reader takes as
internal, so the reduced document holds the marked actions and nothing else.
p.visibility('internal') writes the marking out where an author wants it on
the page.
Field-level permissions
import { policy } from '@evanion/acl';
const access = policy<{ id: string }, { listing: { status: string } }>()
.for('listing', (p) => p.allow('read', p.always).fields(['*', '!status']))
.build();
const fd = access.canFields(
{ id: 's1' },
'listing',
'read',
{ status: 'draft' },
'read',
);
fd.fields['status']; // -> 'denied'The object form of fields keys the per-field configs by field name and takes
fields for the name allow-list, so fields is the one name a field cannot
have: a field called fields has nowhere to put its targets or transitions.
It can still be allowed or denied by name through the list. A matrix that gives
it a config is refused at construction with an InvalidPermissionError naming
the clash.
A field decision carries the action decision it hangs off, and fd.allowed is
true only when the action is allowed and every field is allowed. The field maps
are filled in whatever the action says, so a blocked caller still sees which
fields would be editable once the action is unblocked.
Writing
A write passes the proposed object to canFields and writes what
pickAllowedFields hands back. Every key of the proposed write is decided, and
the returned object holds only the keys that decided allowed — that object is
the value to write, and nothing else from the write is.
import { policy, pickAllowedFields } from '@evanion/acl';
const access = policy<
{ id: string },
{ question: { askedBy: string; body: string; status: string } }
>()
.for('question', (p) =>
p
.allow('update', p.eq('object.askedBy', 'subject.id'))
.fields(['*', '!status']),
)
.build();
const current = { askedBy: 'customer-41', body: 'In stock?', status: 'open' };
const proposed = { body: 'Wingspan in stock?', status: 'locked' };
const fd = access.canFields(
{ id: 'customer-41' },
'question',
'update',
current,
'write',
proposed,
);
fd.action.allowed; // -> true
fd.fields['status']; // -> 'denied'
JSON.stringify(pickAllowedFields(fd, proposed)); // -> '{"body":"Wingspan in stock?"}'pickAllowedFields throws ActionNotAllowedError when the action itself is
refused: no field of a refused action is writable, and an empty object would
read as a lawful write of nothing. A field the action allows but the field rules
deny is a partial write, so that case returns the allowed subset.
A field carries one of three states, and a filter that drops only the denied
keys writes the other two. Below, the handler loaded the question without
status, so the transitions config cannot read the edge it guards:
import { policy, pickAllowedFields } from '@evanion/acl';
type Question = { askedBy: string; body: string; status: 'open' | 'locked' };
const access = policy<{ id: string }, { question: Question }>()
.for('question', (p) =>
p.allow('update', p.eq('object.askedBy', 'subject.id')).fields({
fields: ['*'],
status: { transitions: { open: ['locked'], locked: [] } },
}),
)
.build();
// The row as the handler loaded it, and the write the form posted.
const row = { askedBy: 'customer-41', body: 'In stock?' };
const proposed = { body: 'Is Wingspan in stock?', status: 'open' as const };
const fd = access.canFields(
{ id: 'customer-41' },
'question',
'update',
row,
'write',
proposed,
);
fd.fields['status']; // -> 'unevaluable'
// Dropping only the denied keys writes a status nobody approved.
const narrowed = Object.fromEntries(
Object.entries(proposed).filter(([key]) => fd.fields[key] !== 'denied'),
);
Object.keys(narrowed); // -> ['body', 'status']
Object.keys(pickAllowedFields(fd, proposed)); // -> ['body']fields names what a write may carry, and a per-field config restricts one
field's value. transitions reads the edge from the value the row holds now to
the value the write proposes, so it needs the current value to decide:
import { policy } from '@evanion/acl';
type Question = { askedBy: string; body: string; status: 'open' | 'locked' };
const access = policy<{ id: string }, { question: Question }>()
.for('question', (p) =>
p.allow('update', p.eq('object.askedBy', 'subject.id')).fields({
fields: ['body'],
status: { transitions: { open: ['locked'], locked: [] } },
}),
)
.build();
const asker = { id: 'customer-41' };
const open = {
askedBy: 'customer-41',
body: 'In stock?',
status: 'open',
} as const;
const locked = { ...open, status: 'locked' } as const;
// open -> locked is the one edge the config allows.
const closing = access.canFields(asker, 'question', 'update', open, 'write', {
status: 'locked',
});
closing.fields['status']; // -> 'allowed'
// locked names no edge, so nothing moves a question out of it.
const reopening = access.canFields(
asker,
'question',
'update',
locked,
'write',
{ status: 'open' },
);
reopening.reasons['status']; // -> 'transition-failed'
// A row loaded without `status` gives the config no edge to read.
const unread = access.canFields(
asker,
'question',
'update',
{ askedBy: 'customer-41' },
'write',
{ status: 'locked' },
);
unread.reasons['status']; // -> 'missing-field'
// A key only the posted form carries is still decided, by the name list.
const posted: Partial<Question> = JSON.parse('{"pinned":true}');
const pinning = access.canFields(
asker,
'question',
'update',
open,
'write',
posted,
);
pinning.reasons['pinned']; // -> 'not-listed'
pinning.reasons['askedBy']; // -> 'not-listed'Foreign matrix
A backend that uses its own ACL can expose its matrix as JSON and the frontend
adopts it. The split is provenance: hydratePolicy is your own document coming
back, parseMatrix is somebody else's arriving, and only the second fails
closed on an unknown key.
The preset earns its five lines. A foreign document whose adopter forgot the flag would throw on an unknown key instead of refusing, and which of the two a document gets is the one thing these names have to make obvious.
import { parseMatrix } from '@evanion/acl';
const access = parseMatrix({
permissions: [
{
key: 'question.read',
object: 'question',
action: 'read',
rules: [
{
when: [
{ field: 'subject.roles', op: 'contains', value: 'bookseller' },
],
},
],
},
],
});
access.can({ id: 's1' }, 'question', 'delete').reason; // -> 'unknown-action'parseMatrix validates the document before it returns an evaluator, and every
refusal extends AclConfigError. A consumer that fetches on a schedule keeps
the last document that constructed:
import { AclConfigError, parseMatrix } from '@evanion/acl';
import type { Matrix } from '@evanion/acl';
const refund = {
key: 'orders:order.refund',
object: 'orders:order',
action: 'refund',
} as const;
const bookseller = {
field: 'subject.roles',
op: 'contains',
value: 'bookseller',
} as const;
// The last document `orders` served that constructed.
let adopted = parseMatrix({
version: 'orders@7',
permissions: [{ ...refund, rules: [{ when: [bookseller] }] }],
});
/** Adopt a fetched document, or keep the one in hand and name the refusal. */
function readopt(served: Matrix): string | undefined {
try {
adopted = parseMatrix(served);
return undefined;
} catch (error) {
if (error instanceof AclConfigError) return error.name;
throw error;
}
}
// `orders@8` lost its refund rule's conditions on the way.
const damaged = {
version: 'orders@8',
permissions: [{ ...refund, rules: [{ when: null }] }],
} as never;
readopt(damaged); // -> 'InvalidRuleError'
adopted.version; // -> 'orders@7'
const staff = { id: 'u1', roles: ['bookseller'] };
adopted.can(staff, 'orders:order', 'refund').allowed; // -> trueOne matrix per service
Across a fleet of services, each service authors and evaluates only the matrix it owns. No service evaluates another's document to reach a decision, and there is no merged matrix anywhere.
Object kinds are namespaced by origin — storefront:listing, stock:listing —
because two services that both say listing mean different rows, with different
fields, in different databases. The canonical key is unchanged:
storefront:listing.read still equals `${object}.${action}` character for
character. . is the key delimiter and is refused inside object and action
at construction, which is what makes : safe as the namespace separator.
import { policy } from '@evanion/acl';
type Seller = { id: string; shop: string };
type Listing = { id: string; shop: string };
const storefront = policy<Seller, { 'storefront:listing': Listing }>()
.for('storefront:listing', (p) =>
p.allow('read', p.eq('object.shop', 'subject.shop')),
)
.build();
const ines = { id: 'staff:ines', shop: 'stockholm' };
const listing = { id: 'wingspan', shop: 'stockholm' };
storefront.matrix.permissions[0]?.key; // -> 'storefront:listing.read'
storefront.can(ines, 'storefront:listing', 'read', listing).allowed; // -> true
/** The error a call raises, by name, or `undefined` when it raises none. */
function raised(run: () => unknown): string | undefined {
try {
run();
} catch (error) {
return (error as Error).name;
}
return undefined;
}
const dotted = () =>
policy<Seller, { 'storefront.listing': Listing }>()
.for('storefront.listing', (p) => p.allow('read', p.always))
.build();
raised(dotted); // -> 'InvalidPermissionError'A gateway or a BFF holds one Access per origin and merges no document.
federatedPolicies composes them: it refuses at construction when two origins
claim one permission key, routes each question to the one origin holding that
key, and settles a single instant for the view.
import { federatedPolicies, parseMatrix } from '@evanion/acl';
import type { Decision, Matrix } from '@evanion/acl';
const storefrontMatrix: Matrix = {
permissions: [
{
key: 'storefront:listing.read',
object: 'storefront:listing',
action: 'read',
rules: [
{ when: [{ field: 'subject.roles', op: 'contains', value: 'owner' }] },
],
},
],
};
const stockMatrix: Matrix = {
permissions: [
{
key: 'stock:listing.read',
object: 'stock:listing',
action: 'read',
rules: [
{
when: [
{ field: 'subject.roles', op: 'contains', value: 'bookseller' },
],
},
],
},
],
};
// One Access per origin. Each document belongs to the service that emitted it,
// so each arrives through `parseMatrix` and fails closed. Two origins claiming
// one key throw `OriginCollisionError` on this line, naming both.
const fleet = federatedPolicies({
storefront: parseMatrix(storefrontMatrix),
stock: parseMatrix(stockMatrix),
});
const subject = { id: 'u1', roles: ['bookseller'] };
// One advisory view for a UI, over one instant every origin reads.
const view: Record<string, Decision> = fleet.capabilities(subject);
Object.keys(view).sort(); // -> ['stock:listing.read', 'storefront:listing.read']
view['stock:listing.read']?.allowed; // -> true
view['storefront:listing.read']?.allowed; // -> false
// The origin holding the key answers it. A key nobody holds reaches no origin.
fleet.can(subject, 'stock:listing', 'read').allowed; // -> true
fleet.can(subject, 'shipping:parcel', 'read').reason; // -> 'unknown-action'
// A request touching both origins asks both, and the caller writes the AND.
const shelf = fleet.can(subject, 'storefront:listing', 'read').allowed;
const stock = fleet.can(subject, 'stock:listing', 'read').allowed;
shelf && stock; // -> false
// The member itself, with `canMany`, `canFields`, `readsObject` and `authorize`
// on it, plus the document that origin published.
fleet.get('storefront')?.matrix.permissions.length; // -> 1That view is advisory, in the same tier as a browser's. Every service behind the edge evaluates its own matrix for itself and never trusts the edge's answer.
An unreachable upstream needs no special handling. Its origin is absent from the
record, so every key it would have answered is already
{ allowed: false, reason: 'unknown-action' }.
One permission never gates another, inside one origin or across two. When a
request touches two services, the fan-out is the caller's own && —
a.can(…).allowed && b.can(…).allowed — written where somebody knows whether
they meant AND or OR.
Integrity of a document in transit belongs to the transport, the same way resolving a subject does: the library neither signs a matrix nor verifies one.
A cross-cutting deny
A compliance or fraud team publishes deny rules for permissions it does not own. The owning service fetches them and applies them in its own process, before it constructs its policy:
authored matrix -> applyDenyOverlay -> hydratePolicy -> accessapplyDenyOverlay appends each key's rules to that permission's denyRules and
returns a new matrix. Nothing merges at an edge, nothing but the owner is
authoritative, and the service that enforces the veto is the one that applies it.
The document at the end of that arrow is the one case the names do not divide
cleanly: the owner composed it in this process, so nothing was serialized and
nothing is foreign. It takes hydratePolicy because that is the open-mode
entry, and there the name is wider than its word.
import { applyDenyOverlay, hydratePolicy } from '@evanion/acl';
import type { DenyOverlay, Matrix } from '@evanion/acl';
// The owner's document. `schema.objects.refund` is what opening `refund.issue`
// to a veto obliges it to declare.
const authored: Matrix = {
version: 'refunds@7',
schema: {
objects: { refund: { fields: { region: 'string', amount: 'number' } } },
},
permissions: [
{
key: 'refund.issue',
object: 'refund',
action: 'issue',
rules: [
{ when: [{ field: 'subject.roles', op: 'contains', value: 'owner' }] },
],
},
],
};
// What compliance publishes. A contribution is a `Rule[]`, so it can state a
// deny and nothing else -- no allow, no field rule.
const overlay: DenyOverlay = {
'refund.issue': [
{
id: 'sanctions-hold',
when: [{ field: 'object.region', op: 'eq', value: 'XX' }],
},
],
};
const access = hydratePolicy(
applyDenyOverlay(authored, overlay, { vetoable: ['refund.issue'] }),
{ version: 'refunds@7+veto@41' },
);
const operator = { id: 'u1', roles: ['owner'] };
access.can(operator, 'refund', 'issue', { region: 'SE', amount: 10 }).allowed; // -> true
access.can(operator, 'refund', 'issue', { region: 'XX', amount: 10 }).reason; // -> 'denied'It refuses at apply time, naming the key: a key the target does not define, a key
the target does not list in vetoable, and a condition that does not fit the
target's schema for that key's object kind. The schema obligation is scoped to
the kinds behind vetoable, so a service that opens no extension point owes no
schema.
applyDenyOverlay is pure Matrix -> Matrix, and it can only subtract:
appending deny rules moves the deny side towards a match and never away, and
every branch reached that way is allowed: false. A subject the overlaid matrix
allows was allowed by the authored one, for every object and every instant.
The authoring party runs the same call. A compliance team applies its overlay to the owner's published document in its own CI and finds its own mistake there rather than in the owner's next deploy.
Publishing the rules to a consumer
A BFF in front of an orders service needs to know whether to render a refund button. Without an artifact to read, it writes its own matrix for somebody else's rules, and that copy diverges on the first change nobody propagates. Divergence this way is quiet: the orders service keeps refusing correctly, so nothing is breached and no alert fires, and the button stays hidden against a server that would have allowed the call.
serialize(access, 'reduced') gives the owner an artifact to hand over. Each
permission carries visibility, the reduced form keeps the public ones and
drops the rest, and an unmarked permission is internal, so an older document and
an author who forgot both publish nothing.
import { hydratePolicy, parseMatrix, serialize } from '@evanion/acl';
import type { Matrix } from '@evanion/acl';
const authored: Matrix = {
version: 'orders@7',
maxStale: 300_000,
schema: {
objects: {
'orders:order': { fields: { ownerId: 'string' } },
'orders:ledger': { fields: { period: 'string' } },
},
},
permissions: [
{
key: 'orders:order.refund',
object: 'orders:order',
action: 'refund',
visibility: 'public',
rules: [
{
when: [
{ field: 'subject.roles', op: 'contains', value: 'bookseller' },
],
},
],
denyRules: [
{ when: [{ field: 'subject.tier', op: 'eq', value: 'probation' }] },
],
},
{
key: 'orders:ledger.reconcile',
object: 'orders:ledger',
action: 'reconcile',
visibility: 'internal',
rules: [{ when: [] }],
},
],
};
const orders = hydratePolicy(authored);
const contract = serialize(orders, 'reduced');
contract.permissions.map((p) => p.key); // -> ['orders:order.refund']
Object.keys(contract.schema?.objects ?? {}); // -> ['orders:order']
'visibility' in (contract.permissions[0] ?? {}); // -> false
// The consumer adopts it the way it adopts any foreign document, and reports
// when it last checked the contract was current.
const storefront = parseMatrix(contract, { fetchedAt: Date.now() });
const bookseller = { id: 'u1', roles: ['bookseller'], tier: 'permanent' };
storefront.can(bookseller, 'orders:order', 'refund').allowed; // -> true
storefront.can(bookseller, 'orders:ledger', 'reconcile').reason; // -> 'unknown-action'A kept permission ships whole. Dropping one of its deny rules would turn a
refusal into an allow, and dropping an allow rule or a field config would move
the answer the other way, so a permission goes out with its refusals attached or
it stays internal. That cost lands on the owner: the deny rule above publishes
subject.tier and the word probation to every consumer.
The contract's decisions equal the owner's, key for key, which is the property that makes it worth holding. Answering more conservatively would be a second copy again, and a button hidden by caution looks exactly like a button hidden by a rule.
A contract is terminal. Its permissions arrive unmarked, so a consumer's own reduced serialization is empty and nobody re-publishes another team's rules.
How long a consumer may keep deciding on it
maxStale is the owner's ceiling, in milliseconds, measured from the holder's
last successful freshness check. The holder reports that instant as fetchedAt
and may tighten the bound with its own maxStale, never extend it. Past
fetchedAt + min(the two) every key answers
{ allowed: false, reason: 'stale-contract' }, and the remedy is a fetch of the
document.
Measuring from the last check is what expires a consumer whose poll silently
stopped: such a consumer believes it is fresh and would never start a clock of
its own. A holder that reports no fetchedAt claims no freshness and runs under
no bound, which is every matrix a service authors in its own process.
A stale contract is stale-permissive, and that is survivable for one reason: the owner re-evaluates on its own matrix on every call and refuses. A stale consumer over-shows and never over-grants.
A key another team may veto has to ship
serialize(access, 'reduced', { vetoable }) refuses a listed key the reduction
would drop. Compliance checks its contribution by running applyDenyOverlay
against the published contract, and an internal vetoable key is absent from it,
taking its object kind's schema entry along, so that check would refuse every
contribution it was written to accept.
Testing a contract you consume
@evanion/acl/testing is a secondary entry point. Importing @evanion/acl
pulls none of it in, it imports no test framework, and every helper returns a
value or throws a plain Error, so it runs under vitest, node:test and jest
alike.
A test that calls can and reads the decision needs none of it. What it is for
is the assertion a consumer gets wrong: a fixture matrix written by the consumer
to stand in for the producer's contract is a second copy of somebody else's
rules, which is the failure the contract removes. The consumer pins the
producer's document, fetches the current one in CI, and replays its own
questions against both.
contractDrift reports a removed key apart from a changed decision. The first
is a producer breaking a published contract. The second may be exactly what the
producer intended, and the consumer decides whether its UI still reads
correctly. An added key is neither, and assertNoContractDrift passes on one.
import { contractDrift } from '@evanion/acl/testing';
import type { Matrix } from '@evanion/acl';
// The contract this consumer pinned, checked in beside the test.
const pinned: Matrix = {
version: 'orders@7',
permissions: [
{
key: 'orders:order.refund',
object: 'orders:order',
action: 'refund',
rules: [
{
id: 'bookseller',
when: [
{ field: 'subject.roles', op: 'contains', value: 'bookseller' },
],
},
],
},
{
key: 'orders:order.cancel',
object: 'orders:order',
action: 'cancel',
rules: [{ id: 'anyone', when: [] }],
},
],
};
// What the producer publishes now, fetched in CI. The refund key is gone.
const fetched: Matrix = {
version: 'orders@8',
permissions: [
{
key: 'orders:order.cancel',
object: 'orders:order',
action: 'cancel',
rules: [{ id: 'anyone', when: [] }],
},
],
};
const staff = { id: 'u1', roles: ['bookseller'] };
const drift = contractDrift({
pinned,
fetched,
// The decisions this application renders, named the way a reader reads them.
cases: [
{
name: 'the refund button',
subject: staff,
key: 'orders:order',
action: 'refund',
},
{
name: 'the cancel button',
subject: staff,
key: 'orders:order',
action: 'cancel',
},
],
});
drift.removed; // -> ['orders:order.refund']
drift.changed.map((change) => change.name); // -> ['the refund button']
drift.changed[0]?.fetched.reason; // -> 'unknown-action'A red build when a replayed decision changes
assertNoContractDrift runs contractDrift and throws a ContractDriftError
when the producer removed a published key or a replayed decision changed. The
error carries the whole report, and its message is describeContractDrift of
that report. ContractDriftError extends AclAssertionError, which is the base
class every refusal in this entry point raises.
import { AclAssertionError, ContractDriftError } from '@evanion/acl/testing';
import { assertNoContractDrift } from '@evanion/acl/testing';
import { describeContractDrift } from '@evanion/acl/testing';
import type { Matrix } from '@evanion/acl';
// One refund permission, held by whoever carries `role`.
const refund = (version: string, role: string): Matrix => ({
version,
permissions: [
{
key: 'orders:order.refund',
object: 'orders:order',
action: 'refund',
rules: [
{
id: 'staff',
when: [{ field: 'subject.roles', op: 'contains', value: role }],
},
],
},
],
});
// The producer kept the key and narrowed the role that holds it.
const staff = { id: 'u1', roles: ['bookseller'] };
let caught: unknown;
try {
assertNoContractDrift({
pinned: refund('orders@7', 'bookseller'),
fetched: refund('orders@8', 'supervisor'),
cases: [
{
name: 'the refund button',
subject: staff,
key: 'orders:order',
action: 'refund',
},
],
});
} catch (error) {
caught = error;
}
const drift = caught as ContractDriftError;
drift instanceof AclAssertionError; // -> true
drift.report.removed; // -> []
drift.report.changed[0]?.fetched.reason; // -> 'no-rule-matched'
describeContractDrift(drift.report).split('\n')[0]; // -> 'contract drift, orders@7 -> orders@8'The whole document diff at the same seam
options.diff is a MatrixDiffer<D>, a (pinned, fetched) => D.
contractDrift reads nothing out of the result and carries it onto
report.diff, so passing diffMatrix types report.diff as a MatrixDiff and
passing nothing leaves it undefined. The replayed cases say what this
application's own screens do; the diff says what moved in the document behind
them.
import { diffMatrix } from '@evanion/acl';
import { contractDrift } from '@evanion/acl/testing';
import type { Matrix } from '@evanion/acl';
const bookseller = {
id: 'staff',
when: [{ field: 'subject.roles', op: 'contains', value: 'bookseller' }],
} as const;
const supervisor = {
id: 'supervisor',
when: [{ field: 'subject.roles', op: 'contains', value: 'supervisor' }],
} as const;
const contract = (version: string, rules: readonly unknown[]): Matrix =>
({
version,
permissions: [
{
key: 'orders:order.refund',
object: 'orders:order',
action: 'refund',
rules,
},
],
}) as Matrix;
const report = contractDrift({
pinned: contract('orders@7', [bookseller]),
fetched: contract('orders@8', [bookseller, supervisor]),
diff: diffMatrix,
});
report.changed; // -> []
report.diff?.unchanged; // -> false
report.diff?.findings.map((finding) => finding.kind); // -> ['granted']Reaching stale-contract on purpose
stale-contract is the refusal a consumer's UI meets in production and never in
development, because a matrix authored in-process reports no fetchedAt and
runs under no bound. fixtureClock computes the budget the way the engine
computes it and names the two instants that bracket it: fresh is the last one
every key still decides on, stale the first one that answers
stale-contract.
import { parseMatrix } from '@evanion/acl';
import { assertRefused, fixtureClock } from '@evanion/acl/testing';
import type { Matrix } from '@evanion/acl';
const contract: Matrix = {
version: 'orders@7',
maxStale: 300_000,
permissions: [
{
key: 'orders:order.refund',
object: 'orders:order',
action: 'refund',
rules: [{ id: 'anyone', when: [] }],
},
],
};
const clock = fixtureClock(contract, { fetchedAt: '2026-01-01T00:00:00Z' });
const storefront = parseMatrix(contract, clock.options);
const shopper = { id: 'u1' };
const inside = storefront.can(
shopper,
'orders:order',
'refund',
undefined,
clock.fresh,
);
inside.reason; // -> 'allow'
// `assertRefused` names the key, the reason and the rule when it throws, so a
// failure says which refusal arrived instead of `false !== true`.
const past = storefront.can(
shopper,
'orders:order',
'refund',
undefined,
clock.stale,
);
assertRefused(past, 'stale-contract').allowed; // -> falseAssertions that name the reason
assertAllowed returns the decision it was given, or throws naming the key, the
reason, the rule and the missing paths. assertRefused is the same for a
refusal and takes the reason to assert. assertFieldState asserts one field
of a canFields decision, and reports a field the decision carries no entry for
apart from a field in the wrong state. explainDecision and
explainFieldDecision are the one-line renderings those three throw, available
on their own for a message a test assembles itself.
import { parseMatrix } from '@evanion/acl';
import { AclAssertionError, assertAllowed } from '@evanion/acl/testing';
import { assertFieldState, assertRefused } from '@evanion/acl/testing';
import { explainDecision, explainFieldDecision } from '@evanion/acl/testing';
import type { Matrix } from '@evanion/acl';
const contract: Matrix = {
version: 'orders@8',
permissions: [
{
key: 'orders:order.update',
object: 'orders:order',
action: 'update',
rules: [
{
id: 'staff',
when: [
{ field: 'subject.roles', op: 'contains', value: 'bookseller' },
],
},
],
fields: { fields: ['note'] },
},
],
};
const storefront = parseMatrix(contract);
const staff = { id: 'u1', roles: ['bookseller'] };
const decision = storefront.can(staff, 'orders:order', 'update');
assertAllowed(decision, 'the order editor').rule; // -> 'staff'
explainDecision(decision); // -> '"orders:order.update" allowed with reason "allow", from rule "staff"'
// A shopper holds no role the rule names, so no allow rule matched.
const shopper = { id: 'u2', roles: [] };
const refused = storefront.can(shopper, 'orders:order', 'update');
assertRefused(refused, 'no-rule-matched', 'the order editor').allowed; // -> false
// No proposed write, so the decision covers the row's fields and the listed names.
const fields = storefront.canFields(
staff,
'orders:order',
'update',
{ note: 'held for pickup' },
'write',
);
assertFieldState(fields, 'note', 'allowed').allowed; // -> true
explainFieldDecision(fields); // -> '"orders:order.update" allowed with reason "allow", from rule "staff"; note: allowed (allow)'
let caught: unknown;
try {
assertFieldState(fields, 'total', 'allowed');
} catch (error) {
caught = error;
}
const failure = caught as AclAssertionError;
failure instanceof AclAssertionError; // -> true
failure.message; // -> '"orders:order.update" has no field "total": it carries note'Server-side authorize
authorize binds a subject so a middleware, loader, action, or RSC server
component evaluates without restating it.
import { policy, type Action } from '@evanion/acl';
type Shopper = { id: string; roles: string[] };
// One policy, every object kind the app has. A `.for()` per kind, and one
// bound handle answers for all of them.
type Shelf = { report: { id: string }; listing: { id: string } };
// `report` grants a verb outside the default CRUD set, so it names its own
// vocabulary. `listing` omits one and takes `Action`.
const access = policy<Shopper, Shelf, { report: Action | 'export' }>()
.for('report', (p) =>
p
.allow('read', p.contains('subject.roles', 'bookseller'))
.allow('export', p.contains('subject.roles', 'owner')),
)
.for('listing', (p) => p.allow('read', p.contains('subject.roles', 'owner')))
.build();
const forUser = access.authorize({ id: 'u1', roles: ['bookseller'] });
forUser.can('report', 'read').allowed; // -> true
forUser.can('report', 'export').reason; // -> 'no-rule-matched'The handle carries decisions and nothing else — no matrix, no version. A
caller that ships the document alongside the decisions holds the access it
came from, which it already imports to call authorize at all. One frozen
document is evaluated against many subjects, and offering it off a
subject-bound handle would read as this subject's matrix, which is the
per-subject snapshot the design exists to avoid.
Deciding before the row is loaded
A caller that runs in front of the fetch — an HTTP guard ahead of the handler
that loads the row — gets unevaluable from every object-dependent permission,
every time. unevaluable is not a refusal, so the guard has to let the request
through and trust that something downstream decides properly. Nothing in the
decision tells it apart from a call that happened to lack data.
readsObject answers that from the document rather than from a call.
import { policy } from '@evanion/acl';
type Listing = { id: string; locked: boolean };
const access = policy<{ id: string; roles: string[] }, { listing: Listing }>()
.for('listing', (p) =>
p
.allow('read', p.contains('subject.roles', 'owner'))
.allow('update', p.always)
.deny('