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();: 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')?...; // -> 1can 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.
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 bothEach 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.
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;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 → accessimport { , } 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
- Giving my rules to another service —
serialize,visibilityand the freshness budget - A policy from another service —
parseMatrix, the closed mode, and what construction refuses - Caveats and pitfalls — the layer-trust and subject-forgery entries apply directly here
- Security contract — every layer decides for itself