Skip to Content
AuthorizationRules that another service wrote

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.

The document's loop across two services. orders builds the matrix with a version and a schema, the storefront fetches the served document, keeps the evaluator it holds while the versions match, and adopts the served document with parseMatrix when they differ.

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

Last updated on