The matrix document
Not on npm yet
npm install @evanion/acl does not resolve. The package is private: true, so a release run versions and tags it without publishing: npm cannot configure a trusted publisher for a package that does not exist on the registry, and the first version has to go up by hand.
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
{ "version": 3, "schema": { "objects": {} }, "permissions": [] }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.schema: the shapes the conditions read, optional and binding where present.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.
interface Permission {
key: string; // exactly `${object}.${action}`
object: string;
action: string;
rules?: Rule[]; // allows, OR-ed
denyRules?: Rule[]; // denies, OR-ed; a matched deny wins
fields?: FieldRules; // the field axis
}
interface Rule {
id?: string; // what a decision reports as `rule`; defaults to `#0`, `#1`, …
when?: Condition[]; // AND-ed
}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.
{ field: 'object.askedBy', op: 'eq', path: 'subject.id' } // path comparand
{ field: 'subject.roles', op: 'contains', value: 'bookseller' } // literal comparand
{ field: 'now', op: 'after', value: '2026-01-01T00:00:00Z' }op | 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'
// The construction site states what it is actually running. The option wins,
// and the frozen `matrix` carries the winner.
const = (, { : 'orders@7+veto@41' });
.; // -> 'orders@7+veto@41'
..; // -> 'orders@7+veto@41'hydratePolicy(matrix, { version }) overrides the document’s value, and the
frozen access.matrix carries the winner.
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', : 'string', : 'string[]' },
: { : 'listing' },
},
},
},
: [
{
: '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.