Skip to Content
AuthorizationThe Matrix Document

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' }
opComparandHolds when
eqpath or valuethe two sides are ===
nepath or valuethey are not
invaluevalue is an array containing the field
not-invaluevalue is an array not containing the field
containsvaluethe field is an array containing value
beforevaluethe clock is earlier than value
aftervaluethe 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 not 1;
  • a Date is not the string that spells it;
  • null is 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.

SpellingDeclares
string, number, booleanthe JSON primitive of that name
instanta point in time the clock operators read
a [] suffixan array of the type it follows
a ? suffixa 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:

  1. a condition naming a field the declared kind does not declare, raising UnknownFieldError;
  2. a condition whose operator or comparand does not fit the declared type, raising FieldTypeMismatchError: contains against a non-array, an equality against an array, a literal of the wrong type, or a path comparand 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 fields rules;
  • the targets and transitions keys;
  • 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:

  1. one read of each member of the envelope;
  2. a deep clone of every permission;
  3. a freeze of the result;
  4. validation of the frozen copy.
What hydratePolicy does to a document. It reads each member of the caller's object once, clones every permission out of it, freezes the copy, and validates the frozen copy rather than the original. Nothing downstream reads the caller's object again. The subject and the object bags are not copied, because they are the app's data.

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.

Last updated on