Skip to Content
AuthorizationDecisions

Decisions

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.

Decision is what every entry point of @evanion/acl answers with. allowed is the answer, and the other three fields explain it without a second call.

interface Decision { key: string; allowed: boolean; reason: Reason; rule?: string; // the rule that decided; see the table for which reasons carry it missing?: string[]; // the paths that did not read, on 'unevaluable' }

What can hands back

can answers one Decision for one row.

const
const decision: Decision
decision
= .({ : 's1' }, 'question', 'update', );

Chain a .for() per kind and question, listing and report share one document.

The reasons a Decision carries

reason is output only. Nothing in the library reads a reason back to decide anything, and gating on the reason string grants what the engine refused.

Reason holds the seven values in the table below, and this build checks that. A reason the package adds and the table omits leaves NotInTheTable with a member, which a type constrained to never cannot have:

import type { } from '@evanion/acl'; const = [ 'allow', 'no-rule-matched', 'denied', 'unknown-action', 'unevaluable', 'unusable-clock', 'stale-contract', ] as satisfies readonly []; type < extends never> = ; type = <<, (typeof )[number]>>;
reasonMeaning
allowan allow rule matched; carries rule
no-rule-matchedrules were present and none passed, or there were none
denieda deny rule matched; carries rule
unknown-actionthe key is not in the matrix; the fail-closed path only
unevaluablea rule read a path the object did not carry; carries missing, and rule when the deny side was the unreadable one
unusable-clocka rule read a now that is not an instant; carries rule
stale-contractthe document is past the freshness budget it states

The deny side settles first, so a rule on an unevaluable names the deny rule that could not be read. An allow side left unevaluable carries missing alone.

Default deny, express allow, express deny wins

@evanion/acl allows nothing without an allow rule that matched. No rules, an empty rule list and an unknown action each refuse. Above that, a deny that matched outranks an allow that also matched, and a deny the engine could not read refuses too.

import { } from '@evanion/acl'; type = { : string; : string }; const = <{ : string }, { : }>() .('question', () => .('update', .('object.askedBy', 'subject.id')) .('update', .('object.status', 'locked')), ) .(); const = { : 's1' }; // An allow rule matched, and no deny did. const = .(, 'question', 'update', { : 's1', : 'draft', }); .; // -> 'allow' // Somebody else's question: no allow rule matched. const = .(, 'question', 'update', { : 's2', : 'draft', }); .; // -> 'no-rule-matched' // A matched deny outranks the allow that also matched. const = .(, 'question', 'update', { : 's1', : 'locked', }); .; // -> 'denied' // A projection carrying neither field. The deny side could not be read, so the // permission is not answerable yet — and the answer names what to fetch. const = .(, 'question', 'update', {}); .; // -> false .; // -> 'unevaluable' .; // -> ['object.status', 'object.askedBy']

Seven steps decide one permission, in a fixed order, and Why deny, then allow, then deny? has it.

unevaluable is a third state

unevaluable is neither an error nor a no. The engine could not reach a decision with the data it was handed, and missing names exactly what to fetch.

Absence is unevaluable for every operator, negative ones included. ne, not-in and contains over an absent path are as unevaluable as eq, because a field nobody read says nothing either way.

A caller sees it in two situations:

  • No instance at all. access.can(subject, 'question', 'create') against a permission whose rules read object.*. That is the create toggle, and the call carries no instance to compare against yet.
  • A projection that does not carry the field. A list query selected id and body, and the rule reads object.askedBy.
const decision = access.can(subject, 'question', 'update', { status: 'draft' }); // { key: 'question.update', allowed: false, reason: 'unevaluable', // missing: ['object.askedBy'] }

What to do with one depends on where the call is:

  • Server, enforcing. Treat it as a refusal. allowed is already false, so fetch the named paths and re-ask, or return a 403. It is never a grant.
  • Client, rendering. Render the control as pending, fetch the paths in missing, re-ask once. Both sides’ unreadable paths come back together, so one refetch settles the permission.
  • Authoring. An unevaluable that no refetch repairs is a bug in the matrix, usually a mistyped field name. A schema turns that class into an UnknownFieldError at construction.

Deciding before the row is loaded

An HTTP guard ahead of the handler that loads the row gets unevaluable from every object-dependent permission, every time, and cannot tell that apart from a call that happened to lack data.

access.readsObject(key, action) says which is which, from the document:

import { } from '@evanion/acl'; type = { : string; : boolean }; const = <{ : string; : string[] }, { : }>() .('listing', () => .('read', .('subject.roles', 'owner')) .('update', .) .('update', .('object.locked', true)), ) .(); .('listing', 'read'); // -> false .('listing', 'update'); // -> true

listing.read reads only subject.roles. listing.update carries a deny on object.locked, so a guard ahead of the fetch only ever holds unevaluable:

if (access.readsObject('listing', 'update')) { // Never decidable here. Refuse loudly, or record that the handler still owes // the real decision. Do not wave the request through. }

readsObject is true when any allow or deny rule of this permission names an object.* path, on either operand. It takes no subject, reporting a fact about the matrix.

Field rules do not count. A transitions config reads the object only on the canFields write axis, where the caller holds the row already.

Why only the object

An absent object.* path is unevaluable, and an absent subject.* path is a plain miss.

What the rule readsWhat an absent path does
object.*unevaluable: the object is a projection the caller chose, so a field it lacks says nothing about the row
subject.*fails the condition: the app resolves the subject whole before the call, so a subject carrying no roles has none
one operand in each scopefollows the operand that failed, so an absent subject comparand fails the condition and an absent object comparand leaves it unevaluable
a branch that needs no datadecides a rule that mixes both: p.or(p.eq('object.askedBy', 'subject.id'), p.always) with no instance is allow, because always answers without the object

Asking more than one question

canMany, capabilities and authorize each answer more than one question per call:

access.canMany(subject, 'question', 'update', rows); // one decision per row access.capabilities(subject); // every permission, keyed access.authorize(subject); // a handle with the subject bound
CallWhat it answers
canone question, and a loop of it re-settles the clock each pass
canManyone decision per row, settling the clock once for the whole list
capabilitiesthe whole matrix against one context, which a client hydrates a UI from
authorizea handle with the subject bound, and one instant with it: the now the caller passed, or the moment of the call

Prefer canMany or capabilities over a loop of can.

import { , type } from '@evanion/acl'; type = { : string; : string[] }; // One policy, every object kind the app has. A `.for()` per kind, and one // bound handle answers for all of them. type = { : { : string }; : { : string } }; // `report` grants a verb outside the default CRUD set, so it names its own // vocabulary. `listing` omits one and takes `Action`. const = <, , { : | 'export' }>() .('report', () => .('read', .('subject.roles', 'bookseller')) .('export', .('subject.roles', 'owner')), ) .('listing', () => .('read', .('subject.roles', 'owner'))) .(); const = .({ : 'u1', : ['bookseller'] }); .('report', 'read').; // -> true .('report', 'export').; // -> 'no-rule-matched'

authorize binds the subject, so a middleware, loader, action or React server component evaluates without restating it.

The clock

now is a parameter of every entry point, and the caller supplies it in any Instant form.

What the caller passesWhat the engine reads
nothingthe wall clock
an ISO 8601 stringthat instant; the form survives JSON, so a client-supplied one passes straight through
epoch millisecondsthat instant; the form survives JSON, so a client-supplied one passes straight through
a Datethat instant
an instant that does not parse{ allowed: false, reason: 'unusable-clock' } from every permission whose decision reads it, allow side and deny side alike
import { } from '@evanion/acl'; const = < { : string }, { : { : string } }, { : 'buy' } >() .('sale', () => .('buy', .('now', '2026-01-01T00:00:00Z'))) .(); const = .( .({ : new ('2026-06-01T00:00:00Z') }), ) as { : string }; const = .({ : 's1' }, 'sale', 'buy', , .); .; // -> true const = .('2025-06-01T00:00:00Z'); const = .({ : 's1' }, 'sale', 'buy', , ); .; // -> false

The engine settles now to one epoch per call, so a matrix with many time windows parses the clock once. An unparseable instant never throws and never grants. Decide deliberately what a caller may supply; the security contract has it.

Last updated on