How do I use rules another service published?
@evanion/acl 0.1.0 and its patch releases up to 0.1.2, the version on npm. Values on this page were produced by running the source at 40b8f84, a later commit than the 0.1.0 tag, and are not re-executed.
parseMatrix(json) builds an evaluator over a matrix another service published,
and answers unknown-action for any permission that document does not carry.
Adopting another service’s acl matrix with parseMatrix
Baize’s storefront draws buttons from rules another Baize service owns. A storefront that writes those rules a second time is right until the owner changes one: the owner keeps refusing correctly, no alarm fires, and the button stays hidden against a server that would have allowed the call.
The matrix is the JSON document a policy flattens to, which the Overview
introduces. The owner serves it, and the storefront fetches it and hands it to
parseMatrix:
import { } from '@evanion/acl';
const = ({
: [
{
: 'question.read',
: 'question',
: 'read',
: [
{
: [
{ : 'subject.roles', : 'contains', : 'bookseller' },
],
},
],
},
],
});
.({ : 's1' }, 'question', 'delete').; // -> 'unknown-action'Each permission carries key, object, action and a rules list of allow
rules, and each rule holds a when list of conditions that must all hold. This
document grants question.read to a subject whose roles array holds
bookseller. It carries no question.delete, so asking for delete answers
unknown-action with allowed: false. A real document holds every key its
owner publishes, across every object kind it owns, and the whole list builds one
evaluator.
Why parseMatrix answers where hydratePolicy throws
parseMatrix(json, options?) is
hydratePolicy(json, { ...options, closed: true }). closed: true is the flag
that turns a missing key into a refusal, and it sits after the spread, so no
option a caller passes turns it off. The two calls differ only on a key the
document does not carry:
hydratePolicythrowsUnknownObjectKeyErrorfor an object kind the document lacks, andUnknownPermissionErrorfor an action it lacks.parseMatrixanswersunknown-actionwithallowed: falsefor both.
A malformed document throws at construction on either path.
Which path a document gets depends on where it came from. A key your own policy
lacks is a programmer error, and the throw points at the line. A browser
rebuilding your own access.matrix with hydratePolicy is on that path too,
because your own code wrote both the document and the question. A key a foreign
document lacks means its owner removed the permission, or this consumer was
written against an older revision, and a throw there takes a page down over a
permission nobody holds.
Neither path grants a key it does not hold. The owning service decides again on its own matrix when the call reaches it.
What parseMatrix refuses before an acl evaluator exists
parseMatrix holds a foreign document to the same checks a local one passes,
and each refusal extends AclConfigError. The common ones:
| Refused at construction | When |
|---|---|
InvalidMatrixError | not an envelope, no permissions array, a 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, path, depth or operator |
KeyMismatchError | key is not exactly `${object}.${action}` |
DuplicatePermissionError | two permissions share a key |
UnknownFieldError | a condition names a field the document’s schema omits |
FieldTypeMismatchError | an operator that does not fit the schema’s declared type |
What can throw? tables the rest. A document fetched over the
network can fail for reasons your process cannot fix, so keep the last document
that constructed. The orders: prefix on the object kind names the service that
owns it:
import { , } from '@evanion/acl';
import type { Matrix } from '@evanion/acl';
const = {
: 'orders:order.refund',
: 'orders:order',
: 'refund',
} as ;
const = {
: 'subject.roles',
: 'contains',
: 'bookseller',
} as ;
// The last document `orders` served that constructed.
let = ({
: 'orders@7',
: [{ ..., : [{ : [] }] }],
});
/** Adopt a fetched document, or keep the one in hand and name the refusal. */
function (: Matrix): string | undefined {
try {
= ();
return ;
} catch () {
if ( instanceof ) return .;
throw ;
}
}
// `orders@8` lost its refund rule's conditions on the way.
const = {
: 'orders@8',
: [{ ..., : [{ : null }] }],
} as never;
(); // -> 'InvalidRuleError'
.; // -> 'orders@7'
const = { : 'u1', : ['bookseller'] };
.(, 'orders:order', 'refund').; // -> trueorders@8 lost its rule’s conditions in transit, and InvalidRuleError refuses
it, so a damaged document never becomes a permission that grants everyone. A
rule with an empty when grants everyone, and a permission with an empty
rules list grants nobody. The storefront keeps answering from orders@7, and
readopt returns the class name for an alarm.
A document may carry a schema, which lists the fields of each object kind
under schema.objects. It binds per object kind: a permission on a kind the
schema does not list is unchecked, and a document with no schema checks no path.
What the acl schema refuses
works one through.
Which revision of an acl document is answering
version is a string or a number at the top of the document, and it survives
the JSON round trip, so a consumer holding a document for weeks can say which
revision decided. A process that changes a document before building an evaluator
over it runs a revision the owner never shipped. A deny overlay does that: it
merges another team’s deny rules into the document, as
Many services
covers. hydratePolicy(matrix, { version }) names the revision this process
runs, and parseMatrix takes the same option:
import { } from '@evanion/acl';
const = ({
: 'orders@7',
: [
{
: 'orders:order.refund',
: 'orders:order',
: 'refund',
: [],
},
],
}).;
// This process merged deny overlay 41 into `orders@7` before construction, so
// it runs a revision `orders` never shipped. The option names that revision,
// and the frozen `matrix` carries it.
const = (, { : 'orders@7+veto@41' });
.; // -> 'orders@7+veto@41'
..; // -> 'orders@7+veto@41'The option wins over the document’s own version, and access.matrix, which
the evaluator freezes, 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, and a comparison against it
decides nothing.
Refetching an acl matrix when its version moves
sets out the comparison.
What adopting an acl document does not verify
The library neither signs a document nor verifies a signature. A matrix arriving over an unauthenticated channel is a matrix an attacker wrote, and every 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.