Skip to Content
AuthorizationTyped Authoring

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' }).; // -> false

build() 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.

CallWhat 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.notIncomparisons
p.containsthe field is an array holding the value
p.before p.aftertime windows, over the bare now
p.and p.orgrouping
p.alwaysthe 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 path

What is checked, and what is not

What TypeScript seesWhat it checks it against
the field half of every conditionthe bound subject and object types
the object key of a .for() or a querythe accumulated key map
the object passed to a querythe type that key was bound to
the action namethe vocabulary the kind declares
the comparand valuenothing; 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');
Argument of type '"answr"' is not assignable to parameter of type '"read" | "answer"'.
const = .(); ['question.read']; ['question.raed'];
Property 'question.raed' does not exist on type 'Record<"question.read" | "question.answer", Decision>'. Did you mean 'question.read'?

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.

ErrorRaised when
InvalidMatrixErrorthe envelope is not one: no permissions array, a version of the wrong type
InvalidSchemaErrorthe schema’s own shape is wrong; where locates it inside the document
InvalidPermissionErrora permission’s object, action, rules or fields is malformed
InvalidRuleErrora rule is not an object, or its when is absent or not an array
InvalidConditionErrora condition’s shape, namespace, path depth, or operator/value pairing is unusable
KeyMismatchErrorkey is not exactly `${object}.${action}`
DuplicatePermissionErrortwo permissions share a key
UnknownFieldErrora condition names a field the declared schema does not carry
FieldTypeMismatchErroran operator or comparand does not fit the declared type
BangInAllowListErrora !name entry sits inside an explicit allow-list, which already denies
DenyWithoutBaselineErrora !name entry has no * baseline to subtract from
TargetsTransitionsConflictErrorone field configures both targets and transitions
UnknownObjectKeyErrora 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
UnknownPermissionErrora 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.

Last updated on