Skip to Content
AuthorizationRules that another service wrote

Rules that another service wrote

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.

Every example on this page is a region of libs/acl/README.md and runs under nx test @evanion/acl.

The concept. A matrix is a JSON value carrying a flat list of permissions between object kinds and subjects, and one service builds it while another evaluates the copy it fetched. What you get. A document that declares the field shapes its own conditions read, plus a version string the consumer compares with the one on the copy it holds. Why you want it. A storefront holding an hour-old copy goes on drawing an Edit button for a permission the owner revoked. How the library gets you there. schema checks every condition path at construction, and parseMatrix fails closed on a key the document omits.

The document's life across two processes. The orders service builds a matrix and declares in its schema the field shapes its conditions read, which the builder checks at construction. The storefront calls parseMatrix, which fails closed on a key it does not know, and compares the version it just fetched against the one it already holds to decide whether to fetch again.

What an acl matrix carries between services

A matrix is a plain JSON value, so the service that builds it and the service that evaluates it can be different processes. Baize’s orders service builds the matrix and serves it over HTTP, and the storefront evaluates the copy it fetched, importing nothing of the module the rules were written in. The library writes no file and reads none, so an application that wants a matrix to survive a restart arranges that itself.

The document states the field shapes its own conditions read, and carries a version string a consumer compares with the one on the copy it holds. parseMatrix takes somebody else’s document without throwing on a permission you have never heard of. Two failures follow from a document published without those two fields:

  • Without the schema, Priya publishes a rule from Baize’s orders service testing object.status with contains, the array operator, against a field holding a string, and nothing refuses the document.
  • Without the version, a browser holding an hour-old copy goes on drawing an Edit button for a permission the owner revoked.

A schema on the document refuses an undeclared path with an UnknownFieldError and an ill-fitting operator with a FieldTypeMismatchError, both at construction. version gives the client one value to compare with !==, and parseMatrix fails closed on a key the document does not carry.

Declare the shape

schema declares the field shapes a document’s own conditions read. The typed builder’s types stay in your editor and the document leaves without them, so a document another service reads carries the shapes itself. The schema is optional for a producer and binding wherever it is 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: string, number, boolean, instant, a [] suffix for an array, a ? suffix for a field that may be absent. A producer in any language emits the whole schema by reflection.

Two checks, both at construction: a condition naming an undeclared field is UnknownFieldError; an operator or comparand that does not fit the declared type is FieldTypeMismatchError. The schema binds per kind, so a permission on a kind absent from schema.objects is unchecked.

Priya authors orders against the TypeScript types and writes the schema beside them:

import { } from '@evanion/acl'; type = { : string; : string }; const = <{ : string }, { : }>({ : 'orders@7', : { : { : { : 'string' } }, : { : { : { : 'string', : 'string' } }, }, }, }) .('question', () => .('update', .('object.askedBy', 'subject.id')), ) .(); .(..); // -> '"orders@7"'

The two are checked independently and can disagree. A path TypeScript accepts is still an UnknownFieldError when the schema does not declare it.

What capabilities answers on a document another service wrote

capabilities reads a document orders wrote exactly as it read the shop’s report policy in Rules that read the subject: every permission in it, against one subject, in document order, in a single pass. It passes no object, so a permission whose rules read object.* decides unevaluable. What can they do at all? has the contract.

Adopt a document somebody else wrote

parseMatrix is hydratePolicy with closed: true, for a document this process did not build. The key universe is untrusted, so an unknown object kind or action fails closed and throws nothing.

import { } from '@evanion/acl'; const = ({ : [ { : 'question.read', : 'question', : 'read', : [ { : [ { : 'subject.roles', : 'contains', : 'bookseller' }, ], }, ], }, ], }); .({ : 's1' }, 'question', 'delete').; // -> 'unknown-action'

hydratePolicy and parseMatrix treat an unknown key differently. A key the local typed policy does not carry is a programmer error, and hydratePolicy throws UnknownPermissionError. A key a foreign document does not carry means the producer removed that permission, and throwing there would take a page down over a permission nobody holds. A policy from another service is the whole story.

Version the document, and revalidate against it

version names which foreign document arrived. It is a string or a number and the contract compares it with !==, so a content digest or a composite such as orders@7+veto@41 works where a counter cannot. The document states what the producer published. hydratePolicy(matrix, { version }) states what this construction site is actually running, which the producer cannot know. The option wins, and the frozen access.matrix carries the winner.

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'

Refetch when the version moves

You compare the two version strings yourself, and the library leaves the fetch to you. The storefront holds the matrix it last fetched, and when the owner revokes a permission that copy goes on granting it until somebody fetches again:

import { } from '@evanion/acl'; import type { , Matrix } from '@evanion/acl'; /** Rebuild when the served document moved; otherwise keep the one in hand. */ function (: , : Matrix): { return . === . ? : (); } const : Matrix = { : 'orders@8', : [ { : 'question.read', : 'question', : 'read', : [] }, ], }; let = ({ ..., : 'orders@7' }); = (, ); .; // -> 'orders@8'

The library never pushes an update, and a holder that reports no fetchedAt runs under no expiry. A document that states no version leaves access.version undefined, and a !== over undefined decides nothing. A producer who wants the contract to hold states a version. The orders service never depends on the storefront’s copy: it evaluates its own document before it writes anything.

Where to go next

Last updated on