What can they do at all?
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.
The concept. capabilities resolves every permission in the document
against one subject, passing no object.
What you get. A map keyed by permission, each entry a full decision, which a
navigation menu filters its items against.
Why you want it. A menu holding its own list of permission keys stops
matching the document the day somebody adds a key to one and not the other.
How the library gets you there. capabilities walks the whole document in
one pass, in document order.
What one capabilities call returns
capabilities(subject, now?) resolves every permission in the document against
one subject, passing no object, keyed by permission, in document order, in one
pass. Every entry is a full decision, and the menu filters the map. Baize’s
sidebar renders before any listing, order or report has been fetched, and Jo
sees two of its six entries.
A menu calling can once per entry reads the clock six times and scans the
permission list six times, and its hand-written list of keys goes stale the day
somebody adds a permission. One call at the top of a render replaces that loop.
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 the app has. The one above chains
.for('report') and .for('listing'), and capabilities answers across both
in the single call.
The no-object contract in acl
capabilities passes no object, and that decides what its answers can mean:
- A permission whose rules read only
subject.*decides definitely. The subject was resolved whole before the call, so a field it lacks is a field it does not have. - A permission the subject alone cannot settle decides
unevaluable, withmissingnaming the object paths.falsewould overstate what the engine knows.
A rule that reads the object still settles when its subject conditions decide
the rule on their own. A rule requiring subject.roles to contain bookseller
fails definitely for a customer, whatever object.* paths sit beside it, and
the permission answers no-rule-matched without the row:
`unevaluable` in a capability map is normal
All three rules above read subject.roles, so all three entries decide
definitely. Add a rule that needs object.* to settle and its entry comes
back unevaluable for every subject the rule’s own conditions do not
already decide. That is not a misconfiguration. Read the map as a list of
allowed === true, and never read allowed === false as “refused”.
Driving a menu from an acl capability map
A navigation menu asks the object-free question: is there any point showing this
section. One capabilities call at the top of a render answers it, and each
item is one lookup.
The policy below declares report and stops there. Chain the app’s other kinds
onto the same policy() call and the map grows the keys with it, and the menu
code does not change.
const const caps: Record<"report.read", Decision>caps = .();
so the menu is a filter over it:
const = [
{ : '/reports', : 'Reports', : 'report.read' },
{ : '/listings', : 'Listings', : 'listing.read' },
].(() => [.]?. === true);Compare with === true, and reach the entry with ?.. A key the document does
not carry is absent from the map, and listing.read above is that case. A menu
built on !caps[key]?.allowed shows every item the matrix has never heard of.
This document arrived as JSON, so its keys are string and the miss happens at
runtime. A policy the builder authored carries its keys in the type, and
caps['listing.read'] is then a compile error and never an absent entry.
Make one capabilities call for the whole menu, because it settles one instant
for the whole document. A loop of can settles the clock per permission, so a
before boundary can fall between two items of one render.
What an acl capability map does not replace
A row-level control (the edit button on one question) is a can with that
question, or a canMany over the page of
rows. A capability map cannot answer it, and an entry that came back
unevaluable is the map saying so.
capabilities is also not a per-subject projection to ship to a client. Ship
the client access.matrix and let the client evaluate it locally, which is the
package’s whole premise. A per-subject projection is a second artifact to keep
fresh.
Where to go next
- Can this user do this?. The per-instance question.
- Why was this refused?. Reading
reasonandmissingout of a capability map. - Many services. Folding one view out of several origins.