What may this user do at all?
@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.
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.