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.
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 construction | When |
|---|---|
InvalidMatrixError | not an envelope, no permissions array, bad version |
InvalidPermissionError | a malformed object, action, rules or fields |
InvalidRuleError | a rule whose when is absent or not an array |
InvalidConditionError | an unusable shape, namespace, path depth, or operator |
KeyMismatchError | key is not exactly `${object}.${action}` |
DuplicatePermissionError | two 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
- Many services — a map of adopted documents, one per origin
- The matrix document — the reference: the envelope, the conditions, the version
- Caveats and pitfalls — what a foreign document cannot state