Skip to Content
AuthorizationGiving my rules to another service

Giving my rules to 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 examples on this page are regions of libs/acl/README.md and run under nx test @evanion/acl.

The concept. serialize(access, 'reduced') emits the permissions marked visibility: 'public', each kept whole, as a JSON document.

What you get. A consumer that adopts that document with parseMatrix and decides locally, with no call back to you.

Why you want it. A consumer that cannot read your rules writes its own copy, which your team does not own and cannot correct.

How the library gets you there. Mark a permission visibility: 'public', and maxStale bounds how long a consumer may keep deciding on the copy it fetched.

What an acl contract carries

serialize(access, 'reduced') emits the permissions you marked public as a JSON document a consumer adopts. serialize keeps each marked permission with every rule it carries, drops the rest, and trims the schema to the kinds that survived. The document carries a freshness bound that stops a consumer deciding on a copy you have since changed, and the consumer evaluates the whole against its own subject.

Baize’s orders service decides who may refund an order, and it hands the storefront the four permissions that concern the refund button out of the forty it runs on.

Why an acl contract exists

A backend for frontend (BFF) in front of an orders service that cannot read orders’ rules writes its own. That copy is orders’ permission logic maintained by a team that does not own it, and it diverges the first time orders changes a rule nobody thinks to propagate.

Orders keeps refusing correctly, so nothing is breached and no alert fires. The refund button is hidden against a server that would have allowed the call, and it stays hidden, in a UI bug on a different team’s backlog.

Marking and serializing an acl policy

visibility: 'public' marks a permission for publication. An unmarked permission is internal, and so is a permission whose author forgot the mark.

import { , , } from '@evanion/acl'; import type { Matrix } from '@evanion/acl'; const : Matrix = { : 'orders@7', : 300_000, : { : { 'orders:order': { : { : 'string' } }, 'orders:ledger': { : { : 'string' } }, }, }, : [ { : 'orders:order.refund', : 'orders:order', : 'refund', : 'public', : [ { : [ { : 'subject.roles', : 'contains', : 'bookseller' }, ], }, ], : [ { : [{ : 'subject.tier', : 'eq', : 'probation' }] }, ], }, { : 'orders:ledger.reconcile', : 'orders:ledger', : 'reconcile', : 'internal', : [{ : [] }], }, ], }; const = (); const = (, 'reduced'); ..(() => .); // -> ['orders:order.refund'] .(.?. ?? {}); // -> ['orders:order'] 'visibility' in (.[0] ?? {}); // -> false // The consumer adopts it the way it adopts any foreign document, and reports // when it last checked the contract was current. const = (, { : .() }); const = { : 'u1', : ['bookseller'], : 'permanent' }; .(, 'orders:order', 'refund').; // -> true .(, 'orders:ledger', 'reconcile').; // -> 'unknown-action'
What a reduced serialization keeps. A permission marked visibility public goes out with every rule it carries, deny rules included; an unmarked permission stays internal, and an object kind left with no public permission loses its schema entry too.

The consumer holds one contract per origin and evaluates each against its own subject. Nothing merges them, and the doctrine on Many services is unchanged: every layer decides for itself, and orders decides again on its own matrix when the refund call arrives.

A published permission goes out whole

serialize emits a kept permission with every rule it carries, deny rules included. Drop one deny rule and you turn a refusal into an allow; drop an allow rule or a field config and you move the answer the other way. A permission whose deny rules read an internal concept discloses that concept to every consumer, so either publish it and accept the disclosure or leave it unmarked.

The owner accepts that disclosure. The deny rule in the example above shows subject.tier and the word probation to every consumer.

The contract’s decisions equal orders’ own, key for key. A contract that answered more conservatively would be a second copy again, and a button hidden by caution looks exactly like a button hidden by a rule.

A contract is terminal

A contract’s permissions arrive unmarked, so a consumer’s own reduced serialization comes out empty. That closes the case where one team publishes another team’s rules on again.

How long a consumer may keep deciding on an acl contract

maxStale is the owner’s ceiling, in milliseconds, measured from the consumer’s last successful freshness check. The consumer reports that instant as fetchedAt and may tighten the bound with its own maxStale, never extend it. Past fetchedAt + min(the two) every key answers stale-contract, and the remedy is a fetch of the document.

BoundWho states itWhat it does
maxStale on the documentthe ownersets the ceiling, in milliseconds
maxStale on the consumerthe consumertightens the owner’s ceiling, never extends it
fetchedAtthe consumernames the instant the bound measures from
neither of the twonobodyleaves the consumer claiming no freshness

State maxStale on the document before any consumer reports fetchedAt. A consumer reporting one against a document that states no ceiling gets MissingFreshnessBudgetError at construction, because the owner’s bound is the only one that expires a copy.

A consumer that reports no fetchedAt claims no freshness and runs under no bound, which is every matrix a service authors in its own process.

A stale contract is stale-permissive, and one thing makes that safe: orders re-evaluates on its own matrix on every call and refuses. A stale consumer draws a control the owner revoked, and the write behind that control still fails at orders.

A key another team may veto has to be published

serialize(access, 'reduced', { vetoable }) refuses a listed key the reduction would otherwise drop. A compliance team checks its own contribution against the published contract by running applyDenyOverlay, which merges another team’s deny rules into a document before the document is constructed. An internal vetoable key is absent from that contract, and it takes its object kind’s schema entry along, so the check refuses every contribution it was written to accept.

Where to go next

Last updated on