Rules that another service wrote
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.
Every example on this page is a region of libs/acl/README.md and runs
under nx test @evanion/acl.
The concept. A matrix is a JSON value carrying a flat list of permissions
between object kinds and subjects, and one service builds it while another
evaluates the copy it fetched.
What you get. A document that declares the field shapes its own conditions
read, plus a version string the consumer compares with the one on the copy it
holds.
Why you want it. A storefront holding an hour-old copy goes on drawing an
Edit button for a permission the owner revoked.
How the library gets you there. schema checks every condition path at
construction, and parseMatrix fails closed on a key the document omits.
What an acl matrix carries between services
A matrix is a plain JSON value, so the service that builds it and the service
that evaluates it can be different processes. Baize’s orders service builds
the matrix and serves it over HTTP, and the storefront evaluates the copy it
fetched, importing nothing of the module the rules were written in. The library
writes no file and reads none, so an application that wants a matrix to survive
a restart arranges that itself.
The document states the field shapes its own conditions read, and carries a
version string a consumer compares with the one on the copy it holds.
parseMatrix takes somebody else’s document without throwing on a permission
you have never heard of. Two failures follow from a document published without
those two fields:
- Without the schema, Priya publishes a rule from Baize’s
ordersservice testingobject.statuswithcontains, the array operator, against a field holding a string, and nothing refuses the document. - Without the version, a browser holding an hour-old copy goes on drawing an Edit button for a permission the owner revoked.
A schema on the document refuses an undeclared path with an UnknownFieldError
and an ill-fitting operator with a FieldTypeMismatchError, both at
construction. version gives the client one value to compare with !==, and
parseMatrix fails closed on a key the document does not carry.
Declare the shape
schema declares the field shapes a document’s own conditions read. The typed
builder’s types stay in your editor and the document leaves without them, so a
document another service reads carries the shapes itself. The schema is optional
for a producer and binding wherever it is present.
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'Field types are flat strings: string, number, boolean, instant, a []
suffix for an array, a ? suffix for a field that may be absent. A producer in
any language emits the whole schema by reflection.
Two checks, both at construction: a condition naming an undeclared field is
UnknownFieldError; an operator or comparand that does not fit the declared
type is FieldTypeMismatchError. The schema binds per kind, so a permission on
a kind absent from schema.objects is unchecked.
Priya authors orders against the TypeScript types and writes the schema beside
them:
import { } from '@evanion/acl';
type = { : string; : string };
const = <{ : string }, { : }>({
: 'orders@7',
: {
: { : { : 'string' } },
: {
: { : { : 'string', : 'string' } },
},
},
})
.('question', () =>
.('update', .('object.askedBy', 'subject.id')),
)
.();
.(..); // -> '"orders@7"'The two are checked independently and can disagree. A path TypeScript accepts is
still an UnknownFieldError when the schema does not declare it.
What capabilities answers on a document another service wrote
capabilities reads a document orders wrote exactly as it read the shop’s
report policy in Rules that read the
subject: every permission in it, against one
subject, in document order, in a single pass. It passes no object, so a
permission whose rules read object.* decides unevaluable. What can they do
at all? has the contract.
Adopt a document somebody else wrote
parseMatrix is hydratePolicy with closed: true, for a document this
process did not build. The key universe is untrusted, so an unknown object kind
or action fails closed and throws nothing.
import { } from '@evanion/acl';
const = ({
: [
{
: 'question.read',
: 'question',
: 'read',
: [
{
: [
{ : 'subject.roles', : 'contains', : 'bookseller' },
],
},
],
},
],
});
.({ : 's1' }, 'question', 'delete').; // -> 'unknown-action'hydratePolicy and parseMatrix treat an unknown key differently. A key the
local typed policy does not carry is a programmer error, and hydratePolicy
throws UnknownPermissionError. A key a foreign document does not carry means
the producer removed that permission, and throwing there would take a page down
over a permission nobody holds. A policy from another service
is the whole story.
Version the document, and revalidate against it
version names which foreign document arrived. It is a string or a number and
the contract compares it with !==, so a content digest or a composite such as
orders@7+veto@41 works where a counter cannot. The document states what the
producer published. hydratePolicy(matrix, { version }) states what this
construction site is actually running, which the producer cannot know. The
option wins, and the frozen access.matrix carries the winner.
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'Refetch when the version moves
You compare the two version strings yourself, and the library leaves the fetch to you. The storefront holds the matrix it last fetched, and when the owner revokes a permission that copy goes on granting it until somebody fetches again:
import { } from '@evanion/acl';
import type { , Matrix } from '@evanion/acl';
/** Rebuild when the served document moved; otherwise keep the one in hand. */
function (: , : Matrix): {
return . === . ? : ();
}
const : Matrix = {
: 'orders@8',
: [
{ : 'question.read', : 'question', : 'read', : [] },
],
};
let = ({ ..., : 'orders@7' });
= (, );
.; // -> 'orders@8'The library never pushes an update, and a holder that reports no fetchedAt
runs under no expiry. A document that states no version leaves
access.version undefined, and a !== over undefined decides nothing. A
producer who wants the contract to hold states a version. The orders service
never depends on the storefront’s copy: it
evaluates its own document before it writes anything.
Where to go next
- One policy behind a screen. The fetched document turned into an action bar, and the wording of a control it refuses.
- Many services. One matrix per service, namespaced kinds, and what a gateway holds.
- Caveats and pitfalls. The two gaps an adopted document leaves open, and what a consumer does about each.
- Security contract. What remains yours.