Skip to Content
AuthorizationWhat may this user do at all?

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 answers denied for a deny rule and no-rule-matched for 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, with missing naming 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.

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.

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.

Last updated on