Skip to Content
AuthorizationWhat can they do at all?

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, with missing naming the object paths. false would 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:

One subject against the whole document, in one pass. A permission whose rules read only subject.* settles. A permission whose object rules the subject already decides settles too, as denied or no-rule-matched. What is left is the permission that needs the row, and it comes back unevaluable with missing naming the paths.

`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

Last updated on