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.
reason | Carries | What the UI has |
|---|---|---|
allow | rule | nothing to explain |
no-rule-matched | — | a plain no; this subject, this row |
denied | rule | a plain no, and which rule to name in a log |
unknown-action | — | this document carries no such key; fail closed |
unevaluable | missing | a refetch; the engine never decided |
unusable-clock | rule | the 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 readobject.*. This is the create toggle: there is nothing to compare against yet. - A projection that does not carry the field. The list query selected
idandbody; the rule readsobject.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', ).; // -> trueOne 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:
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 it | What you do |
|---|---|
| a server, enforcing | Treat it as a refusal. Fetch the named paths and re-ask, or return a 403. |
| a client, rendering | Draw the control as pending. Fetch missing, ask once more. A UI that draws it as permitted is broken. |
| authoring the document | An 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 loaded | Every 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
- Why deny, then allow, then deny? — the full precedence order and why it is in that order
- Caveats and pitfalls —
unevaluableis notdenied, and four other things to check - Can this user do this? — where the decision came from