Skip to Content
AuthorizationMany Services

Many services

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 executed examples on this page are regions of libs/acl/README.md and run under nx test @evanion/acl. No app in this repository runs a federated view: apps/shop-api is one origin and the three frontends adopt its document.

The concept. federatedPolicies holds one Access per origin behind a single view and merges no document.

What you get. A view that sends each question to the one origin whose keys carry it.

Why you want it. A storefront and a stock service that both say listing mean different rows in different databases, and a merged matrix cannot tell the two apart.

How the library gets you there. Each origin namespaces its object kinds, and the view routes on the namespaced key.

What a federated view holds, and what it does not

federatedPolicies holds one Access per origin behind one view and routes each question to the single origin whose keys carry it. Each service authors and evaluates only what it owns, and the topology holds no merged matrix anywhere. A gateway built this way also carries the overlay an owner applies when another team publishes a veto.

applyDenyOverlay is pure Matrix → Matrix and runs inside the owner, before it builds its policy.

Baize’s storefront and its stock service both hold rows they call a listing, in two databases with two field sets. A merged view answers about a row that does not exist.

Namespace the object kind by origin

Two services that both say listing mean different rows, with different fields, in different databases. The namespace says so: storefront:listing, stock:listing.

storefront:listing.read still equals `${object}.${action}` character for character, so KeyMismatchError is satisfied by construction and the typed builder takes the namespaced key directly:

policy<Subject, { 'storefront:listing': Listing }>() .for('storefront:listing', (p) => p.allow('read', p.eq('object.shop', 'subject.shop')), ) .build();
Not executed — nothing here runs it

: is the separator because . is the key delimiter, and a . inside object or action is refused at construction:

InvalidPermissionError: permission "storefront.listing.read": "object" carries the key delimiter ".": an object kind namespaced by origin is spelled "origin:kind"

A gateway composes one policy per origin

federatedPolicies takes one Access per origin, keeps every document whole, and returns a view carrying three members: can, capabilities and get.

import { , } from '@evanion/acl'; import type { Decision, Matrix } from '@evanion/acl'; const : Matrix = { : [ { : 'storefront:listing.read', : 'storefront:listing', : 'read', : [ { : [{ : 'subject.roles', : 'contains', : 'owner' }] }, ], }, ], }; const : Matrix = { : [ { : 'stock:listing.read', : 'stock:listing', : 'read', : [ { : [ { : 'subject.roles', : 'contains', : 'bookseller' }, ], }, ], }, ], }; // One Access per origin. Each document belongs to the service that emitted it, // so each arrives through `parseMatrix` and fails closed. Two origins claiming // one key throw `OriginCollisionError` on this line, naming both. const = ({ : (), : (), }); const = { : 'u1', : ['bookseller'] }; // One advisory view for a UI, over one instant every origin reads. const : <string, Decision> = .(); .().(); // -> ['stock:listing.read', 'storefront:listing.read'] ['stock:listing.read']?.; // -> true ['storefront:listing.read']?.; // -> false // The origin holding the key answers it. A key nobody holds reaches no origin. .(, 'stock:listing', 'read').; // -> true .(, 'shipping:parcel', 'read').; // -> 'unknown-action' // The member itself, with `canMany`, `canFields`, `readsObject` and `authorize` // on it, plus the document that origin published. .('storefront')?...; // -> 1

can routes by the canonical key. The origin that answers can(subject, 'storefront:listing', 'read') is the one origin whose key set holds storefront:listing.read. A key no origin holds answers { key, allowed: false, reason: 'unknown-action' } and reaches no member at all.

One Access per origin, each document whole, behind one view. A key goes to the single origin whose key set holds it; a key no origin holds is refused by the view itself and reaches no member.

capabilities(subject, now?) settles one instant and hands that instant to every origin. A view over five origins reads one clock, so a before boundary falls on the same side of every answer in it.

get(origin) returns the Access that origin was registered with, unchanged. canMany, canFields, readsObject and authorize are reached through it, because each of the four answers about one key and the key names the origin.

Two origins claiming one key refuse at construction

federatedPolicies throws when two members declare the same key:

OriginCollisionError: permission key "listing.read" is claimed by two origins, "storefront" and "stock": each origin's keys must be disjoint for one view to hold both

Each document is valid on its own, and two services can each declare { object: 'listing', action: 'read' } with no namespace anywhere. The collision exists only in the process holding both documents, so the edge is where the check runs and the message names both origins.

The check reads permission.key. A deny rule carries none, so a key one origin declares and another only vetoes is no collision.

The edge view is advisory

The view sits in the same tier as a browser’s. Every service behind the gateway evaluates its own matrix and never trusts the edge’s answer. A gateway that allowed the request does not excuse the service behind it, which a caller reaches directly whenever it wants to.

Where the gateway's answer counts and where it does not. The edge view routes a key to the origin whose key set holds it and answers in the same tier as a browser; each service behind it evaluates its own matrix on every call, including a call that reached it without passing the gateway at all.

An unreachable upstream needs no flag

An upstream the gateway cannot reach is absent from the record, so every key that origin owns is already { key, allowed: false, reason: 'unknown-action' } from the view itself. No flag records the outage, and no option turns that answer into an error the caller has to handle.

Each member is still built with parseMatrix, for the reason A policy from another service gives: every document in the record arrived from a process that is not this one, and the closed mode is what makes a key its owner has dropped a refusal.

One permission never gates another

No permission declares a dependency on another, inside one origin or across two. A subject who may read the storefront copy of a listing and may not read the stock copy is two questions, and the caller asks both over the fleet built above:

const ok = fleet.can(subject, 'storefront:listing', 'read').allowed && fleet.can(subject, 'stock:listing', 'read').allowed;
Not executed — nothing here runs it

Deny-overrides, written where somebody knows whether they meant AND or OR. The library does not guess.

A cross-cutting deny is an overlay the owner applies

A compliance or fraud team publishes deny rules for permissions it does not own. The owning service fetches them and applies them in its own process, before it builds its Access with hydratePolicy:

authored matrix → applyDenyOverlay → hydratePolicy → access
import { , } from '@evanion/acl'; import type { , Matrix } from '@evanion/acl'; // The owner's document. `schema.objects.refund` is what opening `refund.issue` // to a veto obliges it to declare. const : Matrix = { : 'refunds@7', : { : { : { : { : 'string', : 'number' } } }, }, : [ { : 'refund.issue', : 'refund', : 'issue', : [ { : [{ : 'subject.roles', : 'contains', : 'owner' }] }, ], }, ], }; // What compliance publishes. A contribution is a `Rule[]`, so it can state a // deny and nothing else -- no allow, no dependency, no field rule. const : = { 'refund.issue': [ { : 'sanctions-hold', : [{ : 'object.region', : 'eq', : 'XX' }], }, ], }; const = ( (, , { : ['refund.issue'] }), { : 'refunds@7+veto@41' }, ); const = { : 'u1', : ['owner'] }; .(, 'refund', 'issue', { : 'SE', : 10 }).; // -> true .(, 'refund', 'issue', { : 'XX', : 10 }).; // -> 'denied'

applyDenyOverlay(matrix, overlay, { vetoable }) appends each key’s rules to that permission’s denyRules and returns a new matrix. Nothing merges at an edge, the owner stays the only authority, and the service that enforces the veto is the one that applies it.

A contribution can only subtract

A contribution is a readonly Rule[], and a Rule is { id?, when? }. fields lives on Permission, and an allow rule is a member of a different array, so a contribution has no way to express an allow or a field rule.

An appended deny rule moves the deny side towards a match and never away. A subject the overlaid matrix allows was allowed by the authored one, for every object and every instant.

Three refusals, all at apply time

applyDenyOverlay throws on three contributions:

Each one names the offending key

A key the target matrix does not define. A key the target does not list in vetoable. A condition that does not validate against the target’s schema for that key’s object kind, which raises the same UnknownFieldError and FieldTypeMismatchError construction raises, run over the contribution.

A matrix owes a schema for the object kinds of its vetoable keys and nothing else. A key opened whose kind has no schema.objects entry is a refusal, because no schema checks contributions to it. A consumer that opens nothing owes no schema.

The contributing team runs the same call in its own build, over the owner’s published subset, and finds its own mistake there. Apply before serializing: a veto is a deny rule, and a published authored matrix allows what the owner refuses.

Publishing the rules to a consumer

serialize(access, 'reduced') emits the permissions marked visibility: 'public', each kept whole, and the consumer adopts the result with parseMatrix. apps/shop-api serves exactly that at GET /api/policy. The consumer decides locally, and the owner decides again when the call arrives.

What this topology does not give you

Integrity of a document in transit belongs to the transport, the same way resolving a subject does. The library neither signs a matrix nor verifies one, so a document arriving over an unauthenticated channel is a document an attacker wrote.

Where to go next

Last updated on