Unreleased changes
These pages document main, which has changes 0.1.2 does not. npm install @evanion/acl gives you 0.1.2, documented here. Values on these pages are produced by running the current source on every build.
What may this user do at all?
access.capabilities(subject) answers every permission in the matrix for one
subject in one call, so a navigation menu draws its items before any row is
fetched.
One acl call behind a whole navigation menu
Baize’s sidebar draws its Reports and Listings entries before the page has
fetched a report or a listing. A sidebar calling can once per entry keeps its
own list of permission keys beside the policy, and that list stops matching the
document the day somebody adds a permission to one and not the other.
capabilities(subject, now?) resolves every permission in the document against
one subject, passing no object, and returns a map keyed by permission in
document order. The optional now fixes the instant that rules comparing the
clock read. Each entry is the decision can returns: allowed is the answer,
and reason names which of seven outcomes decided it, as
Can this user do this? tables. p.contains(path, value) holds
when the array at path includes value:
import { } from '@evanion/acl';
import type { } from '@evanion/acl';
type = { : string; : string[] };
// One policy, every object kind the app has. A `.for()` per kind, and the
// permissions they flatten to live in the same flat list.
const = <
,
{ : { : string }; : { : string } },
{ : | 'export' }
>()
.('report', () =>
.('read', .('subject.roles', 'bookseller'))
.('export', .('subject.roles', 'owner')),
)
.('listing', () => .('read', .('subject.roles', 'owner')))
.();
const = .({ : 'u1', : ['bookseller'] });
.(); // -> ['report.read', 'report.export', 'listing.read']
['report.read']?.; // -> true
['report.export']?.; // -> 'no-rule-matched'
['listing.read']?.; // -> 'no-rule-matched'One policy holds every object kind, so the chain of .for('report') and
.for('listing') puts both kinds in one document and one call answers across
them. An object kind’s actions default to Action: create, read, update
and delete. policy’s third type argument widens that for one kind, so
{ report: Action | 'export' } lets report declare export. Only the owner
role holds report.export and listing.read, so a bookseller gets
no-rule-matched for both.
Why an acl capability map carries unevaluable entries
capabilities passes no object, and that decides what each entry can mean. A
permission settles when it gets a definite true or false:
- A permission whose rules read only
subject.*settles, because the server resolves the subject whole before the call. - A rule that reads
object.*also settles when one of its subject conditions already fails, because no row could make that rule match. It answersdeniedfor a deny rule andno-rule-matchedfor an allow rule. - A permission that still needs the row decides
unevaluable, the answer for a path a rule needs and the call did not carry, withmissingnaming the paths.
p.always is the condition that always holds:
import { } from '@evanion/acl';
import type { } from '@evanion/acl';
type = { : string; : string[] };
type = { : string; : string };
const = <
,
{ : },
{ : | 'hide' }
>()
.('question', () =>
.('read', .)
.('update', .('object.askedBy', 'subject.id'))
.(
'hide',
.('subject.roles', 'bookseller'),
.('object.status', 'open'),
),
)
.();
const = { : 'customer-41', : [] };
const = .();
// Reads only the subject, so it settles.
['question.read'].; // -> 'allow'
// Needs the row to settle, and names what it would read.
['question.update'].; // -> 'unevaluable'
['question.update'].; // -> ['object.askedBy']
// The customer holds no `bookseller` role, so the row cannot change the answer.
['question.hide'].; // -> 'no-rule-matched'The customer’s question.update entry is unevaluable because its one rule
compares object.askedBy with the subject’s id and no question was passed.
false there would say the customer may edit no question, which is wrong for
the ones they asked. question.hide settles without the row: its rule also
requires bookseller in subject.roles, and the customer holds no roles.
Read a capability map as a list of `allowed === true`
An unevaluable entry carries allowed: false because its answer needs a
row. A menu that reads every allowed: false as “may never do this” hides
an entry the subject holds on some rows.
Drawing a menu from an acl capability map
The menu keeps an item when the map carries its key and the entry allows. The
storefront below draws its menu from a document the stock service serves. It
adopts that document with parseMatrix, the call that builds an evaluator over
a document another service wrote and answers unknown-action for a key the
document lacks:
import { , } from '@evanion/acl';
type = { : string; : string[] };
// `stock` writes the report rules and serves the document as JSON. It declares
// nothing about listings.
const = <, { : { : string } }>()
.('report', () =>
.('read', .('subject.roles', 'bookseller')),
)
.();
const = .(.);
const = (.());
const = .({ : 'u1', : ['bookseller'] });
const = [
{ : '/reports', : 'Reports', : 'report.read' },
{ : '/listings', : 'Listings', : 'listing.read' },
];
const = .(() => [.]?. === true);
.(() => .); // -> ['Reports']
// Hiding only what the map refuses shows the key the document never declared.
const = .(() => [.]?. !== false);
.(() => .); // -> ['Reports', 'Listings']A key the document does not carry is absent from the map, and listing.read is
that key here. The leaky filter hides only what the map refuses, so it shows
Listings to every subject. This document arrived as JSON, so its keys are
string and the miss happens at runtime; ?. reaches the absent entry without
throwing. A policy the builder authored types the map by its own keys, and
caps['listing.read'] on it is a compile error.
Make one capabilities call per render. It reads the clock once for the whole
document, where a loop of can reads it per permission. A rule built with
p.before or p.after, the helpers that compare the current instant with a
fixed one, can otherwise flip between two items of one render.
What an acl capability map does not answer
A control on one row, such as the Edit button under one question, needs that
row. Ask can with the question, or
canMany with the page of rows.
An entry that came back unevaluable is the map saying so.
A capability map is also not a copy of the rules to ship to a browser. Ship
access.matrix and let the browser rebuild an evaluator with hydratePolicy,
as the Overview draws it. A map computed per subject is a second
document to keep fresh.
Who can do this
To determine who among a group can perform an action, you use canEach. While capabilities answers every permission for one subject, canEach answers one permission for many subjects. This is useful for editors who need to know, for example, which specific users in a list have the right to delete a particular refund. Because @evanion/acl holds no subjects of its own, the caller must supply the candidates, whether they are saved scenarios or a sample of real users.
The access.canEach(subjects, key, action, object?, now?) call decides one permission for each subject against one object and returns a Decision[] in the order of the provided subjects. Since entry i is simply what can returns for subjects[i], the properties allowed, reason, rule, and missing retain their standard meanings. The caller is responsible for grouping these entries into allowed, denied, or undetermined categories.
import { } from '@evanion/acl';
type = { : string; : string };
type = { : string; : string[] };
const = <, { : }>()
.('refund', () =>
.('delete', .('subject.roles', 'admin'))
.('delete', .('object.ownerId', 'subject.id')),
)
.();
const : [] = [
{ : 'a1', : ['admin'] },
{ : 'o1', : [] },
{ : 'x1', : [] },
];
const = { : 'r1', : 'o1' };
const = .(, 'refund', 'delete', );
.(() => .); // -> ['allow', 'allow', 'no-rule-matched']
// Without the row, an owner rule cannot be decided for anyone it might cover.
const = .(, 'refund', 'delete');
.(() => .); // -> ['allow', 'unevaluable', 'unevaluable']To ensure consistency across the group, @evanion/acl reads the clock once for the entire list (similar to canMany); consequently, any rule built with p.before or p.after will evaluate every subject identically.
The results vary based on the input:
- Omitted object: An object rule becomes
unevaluable, andmissingnames the required paths. These subjects remain undetermined until the row is loaded. - Unknown action: Every entry returns
unknown-action. - Stale document: Every entry returns
stale-contract. - Empty list: Returns an empty list.
Note that canEach requires a list of subjects rather than a description of them. It cannot answer queries like “subjects where role is support and team is billing,” as that would require computing a predicate over the allow and deny rules—a feature @evanion/acl does not provide. Finally, federatedPolicies does not implement canEach. The call answers about one permission, so the caller already knows which origin holds it and reaches it with get(origin).