Rules that another service wrote
@evanion/acl 0.1.0 and its patch releases up to 0.1.2, the version on npm. Values on this page were produced by running the source at 40b8f84, a later commit than the 0.1.0 tag, and are not re-executed.
parseMatrix evaluates a matrix that another service built and served as JSON.
The document’s schema makes parseMatrix throw on a condition that misreads a
field, and its version tells the storefront when its copy is stale.
Adopting an acl matrix with parseMatrix
Baize’s storefront answers questions from a matrix that Baize’s orders service
wrote. The storefront builds its evaluator from the fetched JSON with
parseMatrix and asks it questions through the same can.
The JSON is what policy() flattens to. Each permission carries key, object,
action and a rules list of allow rules. Each rule holds a when list of
conditions, and the engine ANDs them. A condition is { field, op, value }
against a literal or { field, op, path } against another path, so
p.contains('subject.roles', 'bookseller') becomes the one condition below:
import { } from '@evanion/acl';
const = ({
: [
{
: 'question.read',
: 'question',
: 'read',
: [
{
: [
{ : 'subject.roles', : 'contains', : 'bookseller' },
],
},
],
},
],
});
.({ : 's1' }, 'question', 'delete').; // -> 'unknown-action'The document grants question.read to a subject whose roles holds
bookseller, and it carries no question.delete. Asking for delete answers
unknown-action with allowed: false.
hydratePolicy, the call the Overview uses to rebuild your own document
in the browser, builds the same evaluator from the same JSON, and that evaluator
throws UnknownPermissionError on the same question. parseMatrix is
hydratePolicy with its closed option set, and closed turns the throw into
the unknown-action answer. A key your own policy lacks is a programmer error,
and the throw points at the line. A key a foreign document lacks means orders
removed the permission, and a throw there takes the storefront’s page down over a
permission nobody holds.
The version and schema an acl matrix carries
Baize’s orders service writes the question rules with policy() and serves its
access.matrix to the storefront over HTTP. It passes version and schema to
policy(), because both belong to the document and travel inside it:
import { } from '@evanion/acl';
type = { : string; : string };
const = <{ : string }, { : }>({
: 'orders@7',
: {
: { : { : 'string' } },
: {
: { : { : 'string', : 'string' } },
},
},
})
.('question', () =>
.('update', .('object.askedBy', 'subject.id')),
)
.();
.(.(.)).version; // -> 'orders@7'The last line serializes the document the way orders serves it, and the
version comes back out of the JSON.
version is a string or a number naming this revision of the document. schema
declares the type of every field a condition reads, on the subject and per object
kind. The Objects type that catches a misspelled object.askedBy stays in the
orders codebase, so once the document leaves it the schema is what checks the
paths. Field types are flat strings: string, number, boolean, instant for
a point in time (an ISO 8601 string or epoch milliseconds), a [] suffix for an
array and a ? suffix for a field that may be absent. A service in any language
can emit one.
acl writes no file and makes no request. orders serves the document from a
route of its own, and the storefront fetches it.
What the acl schema refuses before an evaluator exists
hydratePolicy and parseMatrix both check a document against its schema
before they return an evaluator. This document declares status as a string and
tests it with contains, which tests an array:
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'hydratePolicy throws FieldTypeMismatchError and returns no evaluator. A
condition naming a field the schema does not declare throws UnknownFieldError
the same way.
Without a schema, a misspelled object.askedBv builds cleanly and decides
unevaluable on every call, with missing naming a path no row ever carries. The
schema is optional in a document and binds per object kind, so a kind absent from
schema.objects is unchecked.
Refetching an acl matrix when its version moves
The storefront’s copy grants what it granted when the storefront fetched it, so a
permission orders withdraws stays granted in that copy until the storefront
fetches again. acl never pushes an update and never fetches. The storefront
fetches the served document on a schedule it chooses and compares the served
version with access.version. Below, question.read carries a rule with an
empty when, which holds for every subject:
import { } from '@evanion/acl';
import type { , Matrix } from '@evanion/acl';
/** Rebuild when the served document moved; otherwise keep the one in hand. */
function (: , : Matrix): {
// `parseMatrix`, so a key the new version dropped refuses and never throws.
return . === . ? : ();
}
const = { : 'question.read', : 'question', : 'read' };
const : Matrix = {
: 'orders@7',
: [
{ ..., : [{ : [] }] },
{
: 'question.update',
: 'question',
: 'update',
: [
{ : [{ : 'object.askedBy', : 'eq', : 'subject.id' }] },
],
},
],
};
// `orders` withdrew `question.update` and published a new version.
const : Matrix = {
: 'orders@8',
: [{ ..., : [{ : [] }] }],
};
const = { : 'jo' };
const = { : 'jo' };
let = ();
.(, 'question', 'update', ).; // -> true
= (, );
.; // -> 'orders@8'
.(, 'question', 'update', ).; // -> 'unknown-action'revalidate keeps the evaluator in hand while the versions match and adopts the
served document when they differ. The held orders@7 copy granted
question.update to Jo, orders@8 dropped it, and after the rebuild the same
call answers unknown-action. revalidate rebuilds with parseMatrix because an
evaluator from hydratePolicy would throw UnknownPermissionError the next time
anything asks about update.
A document with no version leaves access.version undefined on both copies, so
the comparison always matches and the storefront never rebuilds. Any string or
number works as a version, a content digest included. The orders service never
reads the storefront’s copy: it evaluates its own document before it writes
anything.
Where to go next
- Checking permissions in your UI. The storefront draws an action bar from the document it adopted.
- How do I use rules another service published?. Every check
parseMatrixruns on the way in, and theversionoption that overrides the document’s own. - How do I give my rules to another service?. What
orderspublishes, andmaxStale, the limit on how long the storefront may keep deciding on a copy it has not refreshed. - Many services. One matrix per service, and what a gateway holds.