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'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.
| Bound | Who states it | What it does |
|---|---|---|
maxStale on the document | the owner | sets the ceiling, in milliseconds |
maxStale on the consumer | the consumer | tightens the owner’s ceiling, never extends it |
fetchedAt | the consumer | names the instant the bound measures from |
| neither of the two | nobody | leaves 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
- Many services — the topology this publishes across, and the deny overlay a compliance team applies
- A policy from another service — the same crossing from the consumer’s side
- Why was this refused? — what
stale-contracttells a UI