Typed authoring
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.
policy<Subject, Objects, Verbs>() names the subject, the object kinds and the
actions each kind answers for, then returns a builder.
The builder chain
Each .for() names its kind as a value and hands the condition helpers to a
block, so a typo in an object.* or subject.* path is a compile error. Chain
a .for() per kind and one policy covers the whole app. Every kind accumulates
into the same key map and the same document.
import { } from '@evanion/acl';
import type { } from '@evanion/acl';
type = { : string; : string[] };
type = { : string; : 'open' | 'locked' };
type = { : string };
// One policy, every object kind the shop has. Each `.for()` adds a kind and
// keeps the ones before it, so `access` answers for questions and listings
// alike and there is one document to ship.
const = <
,
{ : ; : },
{ : | 'hide' }
>()
.('question', () =>
.(
'update',
.(
.('object.askedBy', 'subject.id'),
.('subject.roles', 'bookseller'),
),
)
.('hide', .('subject.roles', 'bookseller'))
.('delete', .('object.status', 'locked')),
)
.('listing', () =>
.('update', .('object.sellerId', 'subject.id')),
)
.();
const = { : 's1', : [] };
const = { : 's1', : 'open' } as ;
.(, 'question', 'update', ).; // -> true
.(, 'listing', 'update', { : 's1' }).; // -> true
.(, 'listing', 'update', { : 's2' }).; // -> falsebuild() is the terminal call, and it flattens the blocks to the canonical
matrix, so JSON.stringify(access.matrix) emits the same document a foreign
backend would produce. Validation runs once over every block at that point, and
no .for() validates on its own.
The block parameter
.for() hands its block one object carrying the whole permission model.
| Call | What it does |
|---|---|
p.allow(action, ...conditions) | adds an allow rule to that action |
p.deny(action, ...conditions) | adds a deny rule to that action |
p.allowEach(actions, ...conditions) | adds that allow rule to each action named |
p.denyEach(actions, ...conditions) | adds that deny rule to each action named |
p.fields(rules) | attaches field rules to the action last declared, and raises AclConfigError naming the kind when no allow or deny came first, or when a batch named several actions |
p.visibility(visibility) | marks the action last declared for publication, under the same AclConfigError rule |
p.eq p.ne p.in p.notIn | comparisons |
p.contains | the field is an array holding the value |
p.before p.after | time windows, over the bare now |
p.and p.or | grouping |
p.always | the unconditional rule |
Conditions passed to one allow are AND-ed, and p.or inside them flattens to
disjunctive normal form, one rule per branch. Two separate allow calls for the
same action are two rules, the same OR.
Paths versus literals
The builder reads an operand as a path when its type matches
`subject.${string}` | `object.${string}` | 'now', and as a literal value
otherwise:
p.eq('object.status', 'locked'); // compares against the string 'locked'
p.eq('object.askedBy', 'subject.id'); // compares two paths
p.eq('object.askedBy', 'subject.idd'); // compile error naming the pathWhat is checked, and what is not
| What TypeScript sees | What it checks it against |
|---|---|
| the field half of every condition | the bound subject and object types |
the object key of a .for() or a query | the accumulated key map |
| the object passed to a query | the type that key was bound to |
| the action name | the vocabulary the kind declares |
| the comparand value | nothing; comparand types go unchecked |
A kind whose vocabulary is string takes any action, because a foreign document
may carry an action this one does not.
The vocabulary of a kind
policy() takes a third map, kind to the actions that kind answers for. A kind
the map leaves out declares the four Action verbs and nothing else:
const = <, , >()
.('question', () =>
.('read', .)
.('answer', .('object.askedBy', 'subject.id')),
)
.();
.(, 'question', 'answer');
.(, 'question', 'answr');
const = .();
['question.read'];
['question.raed'];Only the block reaches the keys, so a 'reprice' the vocabulary lists and no
block allows puts no question.reprice in capabilities().
A block whose body is a statement with no return hands back no chain to read the
actions off, so its document answers Record<string, Decision>.
allowEach(actions, ...conditions) and denyEach write one rule across several
actions, one ordinary permission each, so the document names every action it
grants. CRUD_ACTIONS holds those four verbs at runtime, and an author hands it
to allowEach to open all four at once.
The document a typed policy emits
policy() takes version and schema, both document fields, and puts them in
the document it flattens to:
import { } from '@evanion/acl';
type = { : string; : string };
const = <{ : string }, { : }>({
: 'orders@7',
: {
: { : { : 'string' } },
: {
: { : { : 'string', : 'string' } },
},
},
})
.('question', () =>
.('update', .('object.askedBy', 'subject.id')),
)
.();
.(..); // -> '"orders@7"'You write a schema by hand. The object map holds Question at the type level
only, so nothing derives a schema from it.
TypeScript and the schema are checked independently and can disagree. A path
TypeScript accepts because the type declares the field is still an
UnknownFieldError at construction when the schema does not declare it. The
schema is binding wherever it is present.
A bound handle
access.object(key) binds one object kind so the key is named once:
const questions = access.object('question');
questions.can(subject, 'update', question);
questions.canFields(subject, 'update', question, 'write', proposed);A bound handle carries can, canMany, canFields and readsObject.
authorize(subject) binds the other axis and carries can, canMany,
canFields and capabilities, dropping readsObject, which takes no subject.
The two compose.
Construction errors
Every error below is an AclConfigError subclass raised while the policy is
built, which is startup for a local matrix and adoption time for a foreign one.
| Error | Raised when |
|---|---|
InvalidMatrixError | the envelope is not one: no permissions array, a version of the wrong type |
InvalidSchemaError | the schema’s own shape is wrong; where locates it inside the document |
InvalidPermissionError | a permission’s object, action, rules or fields is malformed |
InvalidRuleError | a rule is not an object, or its when is absent or not an array |
InvalidConditionError | a condition’s shape, namespace, path depth, or operator/value pairing is unusable |
KeyMismatchError | key is not exactly `${object}.${action}` |
DuplicatePermissionError | two permissions share a key |
UnknownFieldError | a condition names a field the declared schema does not carry |
FieldTypeMismatchError | an operator or comparand does not fit the declared type |
BangInAllowListError | a !name entry sits inside an explicit allow-list, which already denies |
DenyWithoutBaselineError | a !name entry has no * baseline to subtract from |
TargetsTransitionsConflictError | one field configures both targets and transitions |
UnknownObjectKeyError | a query on a local typed matrix names an object kind the document never held; under parseMatrix the key fails closed with reason: 'unknown-action' and nothing throws |
UnknownPermissionError | a query on a local typed matrix names a key the document never held; under parseMatrix the key fails closed with reason: 'unknown-action' and nothing throws |
The last two rows are raised by a query, never by construction.
ActionNotAllowedError is the only error raised outside construction on either
path, and the write path has it.