Can this user do this?
@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.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',
});
.; // -> trueThe 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.
allowed is the answer. The three fields beside it explain that answer and are
output only.
| Field | What it carries | When it rides along |
|---|---|---|
allowed | the answer, and the only field to gate on | every decision |
reason | which answer this is, as one of seven strings | every decision |
rule | the name .id() gave the rule that decided | allow, denied, unusable-clock, and some unevaluable |
missing | the paths the evaluator could not read | unevaluable |
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:
reason | What it says | Where it was taught |
|---|---|---|
allow | an allow rule matched and no deny rule did | Rules about who the user is |
no-rule-matched | nothing granted the action | Rules about who the user is |
denied | a deny rule matched and outranked the allow | Rules about who the user is |
unevaluable | a rule read a path the row did not carry | Rules 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', ).; // -> trueA 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'); // -> truelisting.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
canscans the permission list to check the kind exists;canManyscans once. - The clock, for a policy using
p.beforeorp.after, the helpers that compare the current instant with a fixed one.canManyreads one instant for the whole list where a loop ofcanreads 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.