Skip to Content
AuthorizationA policy from another service

A policy from another service

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.

The concept. parseMatrix builds an evaluator over a policy document another service published. What you get. An evaluator that answers for the keys the document carries and refuses every key it does not. Why you want it. A team that rewrites another service’s rules locally diverges from them the first time that service edits one. How the library gets you there. parseMatrix validates the document at adoption time and runs closed, so an unknown key answers unknown-action instead of throwing.

What parseMatrix builds

parseMatrix builds an evaluator over a JSON matrix this process did not write, which answers for the keys the document carries and refuses every key it does not. parseMatrix validates that document on the way in and runs closed, so a key the document does not carry answers unknown-action and the page stays up. A schema in the document checks the paths the rules name.

A backend with its own access control list (ACL) publishes its matrix as JSON; a frontend or a peer service adopts it. Baize’s orders service owns the rules about refunds and publishes its matrix, and the storefront adopts that document to grey out the refund button.

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

The permissions array there holds one entry, so one key answers. A real document holds every key the producer publishes across every object kind it owns, and that whole list builds one evaluator.

Write the rules a second time in the storefront and they are right until orders changes one. Orders keeps refusing correctly, so no alarm fires, and the refund button stays hidden against a server that would have allowed the call.

Closed mode is the whole difference

parseMatrix(json, options?) is hydratePolicy(json, { ...options, closed: true }). The flag is written last, so no option a caller passes re-opens a foreign document. Which of the two a document gets is a question of provenance: hydratePolicy for your own document coming back, parseMatrix for somebody else’s arriving.

Ask a key the document does not carry, on the open path, and hydratePolicy throws: UnknownObjectKeyError for an object kind it has never heard of, UnknownPermissionError for an action it has not got. Ask the same key on the closed path and parseMatrix answers { allowed: false, reason: 'unknown-action' } for both.

A key a local typed policy does not carry is a programmer error, and the throw points at the line. A key a foreign document does not carry means the producer removed a permission or this consumer was written against an older revision, and a throw there takes down a page over a permission nobody has.

Closed mode on the consumer's side. parseMatrix answers from the adopted rules for every key the document carries, and answers unknown-action with allowed false for every key it does not, so a permission the producer removed does not take the page down. Orders decides again on its own matrix when the refund call arrives.

Neither path grants. unknown-action is allowed: false, so the closed path refuses the call it could not decide, and the producing service decides for itself when the call reaches it.

Adopting a document still validates it

parseMatrix holds a foreign document to every check a local matrix passes, at adoption time:

Refused at constructionWhen
InvalidMatrixErrornot an envelope, no permissions array, bad version
InvalidPermissionErrora malformed object, action, rules or fields
InvalidRuleErrora rule whose when is absent or not an array
InvalidConditionErroran unusable shape, namespace, path depth, or operator
KeyMismatchErrorkey is not exactly `${object}.${action}`
DuplicatePermissionErrortwo permissions share a key

InvalidRuleError covers a document that lost its conditions in transit (null, absent, a string), so such a document never becomes a permission that grants everything. The unconditional grant is spelled as an empty when, which is the form that survives a JSON round trip.

parseMatrix on a payload from the network can fail for reasons this process cannot fix, so catch AclConfigError, keep the last document that constructed, and alarm.

Check a foreign document against a schema

The producer’s schema checks the paths its rules name, so a consumer with no local Question type still gets those paths validated at construction:

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'

The document above declares status as a string and then tests it with contains, which tests an array, so hydratePolicy refuses it with FieldTypeMismatchError. A condition naming a field the schema does not declare at all is UnknownFieldError. Without a schema, object.askedBt raises nothing: that misspelling is a path that never reads, so the permission decides unevaluable forever and a UI refetches forever.

schema.objects declares one entry per object kind, and hydratePolicy checks the rules of each kind against its own entry. A permission on an object kind absent from schema.objects is unchecked, so a partial schema checks part of the document.

Know which document acl is running

version travels on the envelope and survives the round trip, so a consumer holding a document for weeks can say which revision decided:

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'

version is a string or a number, compared with !==. hydratePolicy(matrix, { version }) overrides the document’s own: the document states what a producer shipped, the option states what this site is actually running. The option wins, and the frozen access.matrix carries the winner, so the version that decided is the version that crosses a server-side rendering (SSR) boundary.

A document stating no version leaves access.version undefined, where a !== decides nothing. Refetch when the version moves is the contract; the library supplies the comparison and nothing else.

What adopting a document does not give you

The library does not sign a document or verify one. A matrix arriving over an unauthenticated channel is a matrix an attacker wrote, and every construction check above reads the shape and says nothing about where the bytes came from. Integrity belongs to the transport, the same way resolving a subject belongs to the session layer.

Where to go next

Last updated on