Skip to Content
AuthorizationWhy was this refused?

Why was this refused?

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. Every Decision carries reason, rule and missing beside allowed.

What you get. Which of the seven values reason took, and which rule produced it.

Why you want it. Three of the seven mean the engine never reached an answer, and a screen drawing those as a refusal tells the customer the wrong thing.

How the library gets you there. missing names the paths the object did not carry, which is the list to refetch.

The seven reasons a decision carries

reason says why the answer came out the way it did, and takes one of seven values.

reasonCarriesWhat the UI has
allowrulenothing to explain
no-rule-matched—a plain no; this subject, this row
deniedrulea plain no, and which rule to name in a log
unknown-action—this document carries no such key; fail closed
unevaluablemissinga refetch; the engine never decided
unusable-clockrulethe now this call passed is not an instant
stale-contract—fetch the document again; this copy is old

reason, rule and missing are output only. allowed is the gate, and nothing else on the decision should ever reach an if.

unevaluable names paths in missing, and one refetch settles it. No refetch changes unusable-clock: fix the now the call passes.

`unevaluable` is not `denied`

Both carry allowed: false, and only one of them is an answer. A denied says the rules refused this subject on this row. An unevaluable says the engine could not read a path it needed, so a UI that draws it as forbidden hides a control the customer is entitled to.

A fresh fetch of the document repairs stale-contract. Tell parseMatrix when your process last validated its copy, with fetchedAt, and every key answers stale-contract once that copy is older than the maxStale the document states. A holder reporting no fetchedAt never sees the reason.

rule, which rule decided

rule names the rule behind a decision a rule made. rule defaults to #0, #1, … by position, and a rule that states an id reports that id instead. Give the rules a UI has to name an id, because a position moves the first time somebody reorders the list.

Treat rule as a log line and a support answer (“denied by locked-question”). Never branch on it: two rules can grant the same thing, and which one matched is not a property the app should depend on.

allow, denied and unusable-clock always carry rule. An unevaluable carries it when the deny side raised it, and carries none when the allow side did, because the deny side is the only one that names a rule on that path.

When the answer is unevaluable

unevaluable says the engine never reached an answer. allowed is already false, so nothing leaks, and missing names exactly what to fetch.

A caller meets it in two situations:

  • No instance at all. can(subject, 'question', 'create') against a permission whose rules read object.*. This is the create toggle: there is nothing to compare against yet.
  • A projection that does not carry the field. The list query selected id and body; the rule reads object.askedBy.
import { } from '@evanion/acl'; type = { : string; : string }; const = <{ : string }, { : }>() .('question', () => .('update', .('object.askedBy', 'subject.id')), ) .(); // The list query selected `id` and `body`; the rule reads `askedBy`. const = { : 'q1', : 'Does this ship sleeved?' }; const = .({ : 's1' }, 'question', 'update', ); .; // -> 'unevaluable' .; // -> ['object.askedBy'] // Fetch exactly what `missing` names, then ask once more. const = { ..., : 's1' }; .({ : 's1' }, 'question', 'update', ).; // -> true

One policy carries every object kind, so the same access answers this way for listings and orders. Both sides’ unreadable paths come back in one missing, so one refetch settles the permission however many rules read the object:

The repair loop for one unevaluable. Both sides' unreadable paths arrive in one missing array, so a single query fetches all of them and the second ask decides. An unevaluable that survives the refetch is a mistyped field name in the document.

Every operator treats an absent object.* path as unevaluable, the negative ones included. “It is not equal to published” states no fact about a field nobody read, so ne, not-in and contains answer there exactly as eq does.

What to do with an unevaluable

Handle an unevaluable by where you meet it.

Where you meet itWhat you do
a server, enforcingTreat it as a refusal. Fetch the named paths and re-ask, or return a 403.
a client, renderingDraw the control as pending. Fetch missing, ask once more. A UI that draws it as permitted is broken.
authoring the documentAn unevaluable no refetch repairs is a bug in the document, almost always a mistyped field name. A schema turns that class into an UnknownFieldError at construction.
before the row is loadedEvery object-dependent permission answers unevaluable. access.readsObject(key, action) tells that case apart from a call that happened to lack data.

Deciding before the row is loaded covers the last row.

Why only the object

An absent object.* path is unevaluable; an absent subject.* path is a plain miss. Your query chose the object projection, so a field it lacks says nothing about the row. The app resolves the subject whole before the call and never projects it, so a subject carrying no roles has none.

Explaining one field of an acl decision

FieldReason is a vocabulary of its own, and every read-only input carries one. not-listed means the rules exclude the name. targets-failed and transition-failed point at a value rather than at the name: the proposed one in the first case, the edge out of the current one in the second. missing-field is the field-level unevaluable and wants the same refetch. Field permissions has every FieldReason in one table, including proposed-required.

Where to go next

Last updated on