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.
The matrix: your rules as JSON
A Matrix is a plain JSON envelope over a flat list of permissions. The matrix
is the whole configuration, and what it does not say the library does not know.
The envelope
JSON.stringify(access.matrix) prints the envelope a typed policy flattens to:
import { , } from '@evanion/acl';
type = { : string };
const = <{ : string }, { : }>({
: 3,
: { : { : { : { : 'string' } } } },
})
.('question', () =>
.('update', .('object.askedBy', 'subject.id')),
)
.();
const = .(.);
; // -> '{"version":3,"schema":{"objects":{"question":{"fields":{"askedBy":"string"}}}},"permissions":[{"key":"question.update","object":"question","action":"update","rules":[{"when":[{"field":"object.askedBy","op":"eq","path":"subject.id"}]}]}]}'
const = (.());
.({ : 's1' }, 'question', 'update', { : 's1' }).; // -> true
// The permission list on its own is no document.
let = '';
try {
(.().permissions);
} catch () {
= ( as Error).;
}
; // -> 'InvalidMatrixError'A matrix has no bare-array form, and a producer in any language states
version and schema in the JSON it emits.
version: what the revalidate contract compares, optional.schema: the shapes the conditions read, optional and binding where present.maxStale: how many milliseconds a consumer may keep deciding on a copy it fetched, optional. How long a consumer may keep deciding has the arithmetic.permissions: the flat list, one entry per object-and-action pair.
One permission
A Permission names one object-and-action pair and carries the rules that
decide it. rules are the allows and denyRules the denies, each list OR-ed, and
a matched deny wins. A rule’s when is AND-ed, and its id is what a decision
reports as rule, derived from when where the rule states none.
import { } from '@evanion/acl';
import type { Permission } from '@evanion/acl';
const : Permission = {
: 'question.update', // exactly `${object}.${action}`
: 'question',
: 'update',
: [
{
: 'asker-edits',
: [{ : 'object.askedBy', : 'eq', : 'subject.id' }],
},
],
: [
{
: 'locked-is-final',
: [{ : 'object.status', : 'eq', : 'locked' }],
},
],
: { : ['*', '!status'] }, // the field axis
};
const = ({ : [] });
const = (: string) =>
.({ : 's1' }, 'question', 'update', { : 's1', });
('open').; // -> 'asker-edits'
('locked').; // -> 'locked-is-final'One document holds every object kind, so question.update and
listing.update sit in the same envelope.
key must equal `${object}.${action}`, and neither half may contain the
dot. Lookup is by key alone, and a mismatch raises KeyMismatchError.
A rule with an empty when is the unconditional grant. when is required and
must be an array, so a document whose conditions arrive as null, absent or a
string raises a construction error.
Conditions
One condition reads one path and compares it to one comparand.
import { } from '@evanion/acl';
import type { } from '@evanion/acl';
const : [] = [
{ : 'object.askedBy', : 'eq', : 'subject.id' }, // path comparand
{ : 'subject.roles', : 'contains', : 'customer' }, // literal comparand
{ : 'now', : 'after', : '2026-01-01T00:00:00Z' }, // the clock
];
const = ({
: [
{
: 'question.update',
: 'question',
: 'update',
: [{ }],
},
],
});
const = { : 'customer-41', : ['customer'] };
const = { : 'customer-41' };
const = (: string) =>
.(, 'question', 'update', , );
('2026-06-01T00:00:00Z').; // -> true
('2025-06-01T00:00:00Z').; // -> falseop | Comparand | Holds when |
|---|---|---|
eq | path or value | the two sides are === |
ne | path or value | they are not |
in | value | value is an array containing the field |
not-in | value | value is an array not containing the field |
contains | value | the field is an array containing value |
before | value | the clock is earlier than value |
after | value | the clock is later than value |
A path is namespaced and one level deep, in one of three forms:
subject.x, read from the subject the app resolved;object.y, read from the instance the call carried;- the bare
now, read from the clock.
A condition carries no nesting and no relation traversal, so a consumer resolves a related row and passes it as the object.
Comparison is strict on both sides of a JSON boundary:
'1'is not1;- a
Dateis not the string that spells it; nullis not absence.
The version
version is a string or a number, and the revalidate contract compares it with
!==. A content digest or a composite such as orders@7+veto@41 works.
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'hydratePolicy(matrix, { version }) overrides the document’s value, and the
frozen access.matrix carries the winner:
import { } from '@evanion/acl';
const = ({
: 'orders@7',
: [
{
: 'orders:order.refund',
: 'orders:order',
: 'refund',
: [],
},
],
}).;
// 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 = (, { : 'orders@7+veto@41' });
.; // -> 'orders@7+veto@41'
..; // -> 'orders@7+veto@41'A document that states no version leaves access.version undefined, and a !==
against undefined decides nothing.
The schema
schema declares the shapes the conditions read. The field is optional, and
binding wherever present.
import { } from '@evanion/acl';
import type { Matrix } from '@evanion/acl';
const : Matrix = {
: {
: { : { : 'string' } },
: {
: { : { : 'string', : 'string' } },
},
},
: [
{
: 'question.update',
: 'question',
: 'update',
// `status` is a string, and contains tests an array.
: [
{ : [{ : 'object.status', : 'contains', : 'draft' }] },
],
},
],
};
let = '';
try {
();
} catch () {
= ( as Error).;
}
; // -> 'FieldTypeMismatchError'Field types are flat strings, so a producer emits the whole schema by reflection.
| Spelling | Declares |
|---|---|
string, number, boolean | the JSON primitive of that name |
instant | a point in time the clock operators read |
a [] suffix | an array of the type it follows |
a ? suffix | a field that may be absent from a full instance |
A declared field that is present and null is present.
A present schema checks two things, both at construction:
- a condition naming a field the declared kind does not declare, raising
UnknownFieldError; - a condition whose operator or comparand does not fit the declared type,
raising
FieldTypeMismatchError:containsagainst a non-array, an equality against an array, a literal of the wrong type, or apathcomparand whose two sides disagree.
A schema binds per kind: a permission on an object kind absent from
schema.objects is unchecked, and subject.* paths are unchecked unless
schema.subject is declared. A schema does not check:
- the names in
fieldsrules; - the
targetsandtransitionskeys; - whether a permission can ever decide
unevaluable.
Without a schema, object.askedBv raises nothing: the path never reads, so the
permission reports unevaluable forever.
Adopting a foreign matrix
parseMatrix is hydratePolicy with closed: true: the key universe is
untrusted, so an unknown object kind or action fails closed and throws nothing.
A backend with its own access control list (ACL) publishes its matrix as JSON
for the frontend to adopt.
import { } from '@evanion/acl';
const = ({
: [
{
: 'question.read',
: 'question',
: 'read',
: [
{
: [
{ : 'subject.roles', : 'contains', : 'bookseller' },
],
},
],
},
],
});
.({ : 's1' }, 'question', 'delete').; // -> 'unknown-action'A key the local typed policy does not carry throws UnknownPermissionError. A
key a foreign document does not carry means the producer removed that
permission, and parseMatrix answers unknown-action rather than throwing.
What construction does to the document
hydratePolicy does not keep the caller’s document. Construction takes four
steps:
- one read of each member of the envelope;
- a deep clone of every permission;
- a freeze of the result;
- validation of the frozen copy.
A caller’s document is a live object, and an accessor on it can answer a second
read differently: a when that validates as a condition could clone as the
empty, unconditional grant. Nothing downstream reads the caller’s object again,
including through the exposed access.matrix.
The subject and the object are not copied. The engine reads the app’s data live as the decision walks the rules. What that implies for a lazy ORM row is in the security contract.