Skip to Content
AuthorizationCan this user do this?

Unreleased changes

These pages document main, which has changes 0.1.2 does not. npm install @evanion/acl gives you 0.1.2, documented here. Values on these pages are produced by running the current source on every build.

Can this user do this?

access.can(subject, kind, action, row) answers one authorization question and returns a decision your handler gates on and your screen renders.

The problem one acl call solves

A customer clicks Edit on a question somebody else asked. Two places need the same answer: the save handler has to refuse, and the Edit button had to be grey before the click. can answers both with the same call, on the server that saves and in the browser that draws the button:

import { } from '@evanion/acl'; type = { : string; : string }; const = <{ : string }, { : }>() .('question', () => .('update', .('object.askedBy', 'subject.id')), ) .(); const = .({ : 'customer-41' }, 'question', 'update', { : 'q7', : 'customer-41', }); .; // -> true

The handler reads allowed, and the Edit button reads the same fields to pick its label. Both calls evaluate the same matrix, so the two cannot disagree about whether the edit was allowed.

Why an acl decision is not a boolean

A boolean tells the save handler whether to write the question and tells the Edit button above it nothing. A component holding only true or false explains a greyed-out button by calling can again or by guessing a string from context.

The same call, two consumers reading different fields of its decision.

allowed is the answer. The three fields beside it explain that answer and are output only.

FieldWhat it carriesWhen it rides along
allowedthe answer, and the only field to gate onevery decision
reasonwhich answer this is, as one of seven stringsevery decision
rulethe name .id() gave the rule that decidedallow, denied, unusable-clock, and some unevaluable
missingthe paths the evaluator could not readunevaluable

rule is sometimes absent, and the reason is the two kinds of rule. A deny rule, written with p.deny, refuses whenever it matches and outranks every allow, and .id(name) after a rule gives it the name rule reports. When the evaluator cannot read the paths a deny rule compares, that one rule is what stopped it, so rule names it. Allow rules are alternatives and any one of them grants, so when the evaluator cannot read the paths they compare no single rule is at fault, and rule is absent. missing arrives either way and names the same fields to fetch.

The seven reasons an acl decision can carry

Seven strings can arrive in reason. Four come out of rules that read the subject or the row, and the table names the page that teaches each:

reasonWhat it saysWhere it was taught
allowan allow rule matched and no deny rule didRules about who the user is
no-rule-matchednothing granted the actionRules about who the user is
denieda deny rule matched and outranked the allowRules about who the user is
unevaluablea rule read a path the row did not carryRules about the thing being accessed

allowed is false for no-rule-matched, denied and unevaluable, so a handler gating on allowed alone is correct for all four. Three more, unknown-action, unusable-clock and stale-contract, come from the document or the call, and Why was this refused? says which situation produces each. Why deny, then allow, then deny? sets out the order in which the evaluator weighs deny and allow rules.

Asking acl when your query selected half the row

A list query selects only the columns the list needs, a projection of the row, and a rule may read a column the list did not select. can takes a partial object, so pass what you have:

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

A projection carrying every path the rules read decides definitely. One that does not answers unevaluable, and missing names exactly what to fetch, so the second call is the only extra query. unevaluable carries allowed: false, so a handler gating on allowed is right either way.

The subject has no partial form. An absent subject.* path fails its condition and names nothing to fetch: an allow rule that reads it does not grant, and a deny rule that reads it does not refuse. A subject missing suspended gets past deny('read', p.eq('subject.suspended', true)), so resolve the subject whole before the call.

Skipping the fetch an acl permission does not need

access.readsObject(kind, action) answers from the document whether a permission reads the row at all:

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, so a caller that asks readsObject first can skip the query. listing.update grants every subject through p.always, a condition every subject meets, and carries a deny rule on object.locked, so readsObject answers true.

That answer matters most to an HTTP guard, middleware that runs before the handler loads the row. The guard gets unevaluable from every permission that reads the row, every time, and cannot tell that apart from a call that happened to lack data. readsObject names the permissions the guard can never settle, so the guard refuses those requests outright, or marks each one as a decision the handler still owes once it has loaded the row. The Decision object every check returns carries the worked case.

Asking acl about a page of rows

canMany takes the rows and answers per row:

import { } from '@evanion/acl'; type = { : string; : string }; const = <{ : string }, { : }>() .('question', () => .('update', .('object.askedBy', 'subject.id')), ) .(); const = [ { : 'q1', : 's1' }, { : 'q2', : 's2' }, { : 'q3' }, // the projection this row came back in lacks the field ]; const = .({ : 's1' }, 'question', 'update', ); const = .(() => .); ; // -> ['allow', 'no-rule-matched', 'unevaluable']

The array is parallel to the input, so the decision for rows[i] is decisions[i]. Nothing is filtered out and a refused row keeps its entry, which is what lets the table draw the refusal beside the row.

canMany still decides each row separately, because the rules read the object and the objects differ. It does two things once for the whole list where a loop of can does them per row:

  • The object-kind lookup. Each can scans the permission list to check the kind exists; canMany scans once.
  • The clock, for a policy using p.before or p.after, the helpers that compare the current instant with a fixed one. canMany reads one instant for the whole list where a loop of can reads the wall clock per row, so a time boundary cannot fall partway down one page of rows.

can, canMany, canFields and capabilities also take that instant from you, as a trailing argument: access.canMany(subject, 'question', 'update', rows, now). Why was this refused? covers what now accepts and what an instant that does not parse decides. Prefer canMany over a loop of can, so one rendering reads one instant.

Dropping what every acl call site repeats

Every handler in a request passes the same subject. access.authorize(subject) returns a handle with the subject bound, and one instant with it, taken from { now } or from the moment of the call:

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'

The third type parameter names the actions a kind may declare. report adds export to Action, which is create, read, update and delete, and a kind that names none gets Action.

The handle carries can, canMany, canFields and capabilities, each with the subject dropped. readsObject stays on access, because it reads the document and takes no subject. access.object(kind) binds the object kind instead, and carries can, canMany, canFields and readsObject. Typed authoring shows both.

Binding the subject is all the library can do about complete mediation, the rule that every path to your data is checked. Nothing stops a handler that never calls can at all.

Last updated on