Can this user do this?
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. can answers one authorization question, over one subject,
one object kind, one action and one row.
What you get. A Decision carrying allowed for the handler and reason,
rule and missing for the screen.
Why you want it. A check written by hand in the handler and again in the
component states the rule twice, and tightening one leaves the other granting.
How the library gets you there. can decides one row, and canMany decides
a page of rows in one call.
One acl question and its decision
can answers one authorization question and returns a decision the handler
gates on and the UI renders. Jo clicks Edit on a question somebody else asked,
and that one call refuses the save and tells the button what to say. Ask with
the row in hand, or over a page of forty rows with canMany, which answers the
same question per row.
Write the check by hand and you write it twice, once in the handler that refuses the save and once in the component that greys out the button. The two drift, and the customer sees an enabled button that 403s.
authorize binds the subject so no handler restates it, and
access.object(kind) binds the object kind. can(subject, kind, action, object?, now?) takes the object kind in kind, so you pass 'question' and
never 'question.update'.
import { } from '@evanion/acl';
type = { : string; : string };
const = <{ : string }, { : }>()
.('question', () =>
.('update', .('object.askedBy', 'subject.id')),
)
.();
const = .({ : 's1' }, 'question', 'update', {
: 's1',
});
.; // -> trueThe policy above declares one object kind. Chain a .for() per kind to put the
questions and the listings in the same document, and do not build a policy per
resource.
Why an acl decision is not a boolean
A boolean tells the handler whether to run the save and tells the Edit button
nothing. A component holding only true or false explains a greyed-out button
by calling again to work it out or by guessing a string from context.
allowed is the answer, and three more fields explain it:
reason comes back on every decision and says which of the seven answers this
one is. rule names the rule that decided, by its id or by #n for its
position; it rides on allow, on denied, on unusable-clock, and on an
unevaluable the deny side raised. missing lists the paths the engine could
not read, and it rides on unevaluable. An unevaluable the allow side raised
carries missing and no rule, because only the deny side names a rule there.
All of them are output only. Gate on allowed, because a gate on the reason
string grants what the engine refused. Why was this refused? is
what to do with the rest.
The four shapes an acl decision comes back in
Four shapes come out of can, and one policy produces all four:
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']Default deny is the floor. No rules, an empty rule list and an unknown action all refuse. Above it, a matched deny outranks a matching allow, and a deny the engine could not read refuses as well.
Asking acl with a projection
The object parameter takes a partial. A list query that selected id and
askedBy passes what it has, and the typed path checks those field names
against the object type without demanding the rest of the row.
access below is the policy the quick-start built, and subject is the
customer asking:
const subject = { id: 's1' };
access.can(subject, 'question', 'update', { askedBy: 's1' });
access.object('question').can(subject, 'update', {});A projection that carries every path the rules read decides definitely. One that
does not comes back unevaluable, with missing naming what to fetch. Both are
allowed: false, so gate on allowed and both come out right. Gate on reason
and a projection turns into permission the engine never granted. Caveats and
pitfalls has that one first.
Asking acl whether a permission needs the row
access.readsObject(kind, action) answers, from the document, whether a
permission needs the row at all. A permission that reads only the subject
settles with no object. Ask readsObject before the database query when you
hold a projection and want to know which kind of permission you are asking
about.
Why an acl subject has no partial form
The subject has no partial form. An absent subject.* path is a definite miss.
If you project the subject, the call refuses with no-rule-matched and names
nothing to fetch. The app resolves the subject whole, and the type says so.
Asking about a list
canMany takes the instances and answers per instance:
import { } from '@evanion/acl';
type = { : string; : string };
const = <{ : string }, { : }>()
.('question', () =>
.('update', .('object.askedBy', 'subject.id')),
)
.();
const = [
{ : 'c1', : 's1' },
{ : 'c2', : 's2' },
{ : 'c3' }, // 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, so
the table draws the refusal beside the row.
A page that lists questions and listings together asks one canMany per kind
against the same access.
What canMany costs
canMany is not a bulk optimisation. It decides each instance separately,
because a rule reads the object and the objects differ. Two pieces of work it
does once for the whole list, where a loop of can does them per row:
- The clock.
nowis parsed into a single epoch for the list. A loop ofcanwith nonowreadsDate.now()per row, so a time window can close partway down the page. - The object-kind lookup. Each
canscans the permission list to check the object kind exists.canManyscans once.
Prefer canMany for the clock. One rendering then reads one instant, and the
speed is a side effect.
Binding what an acl call repeats
Every handler in a request passes the same subject, and most of them name the
same object kind. authorize and access.object take those two out of the
signature once:
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 two handles are alternatives, so bind the axis the call site repeats:
authorizebinds the subject and one instant. With nonowthe instant is the moment you call it, andauthorize(subject, { now })names the instant yourself. The handle returnscan,canMany,canFieldsandcapabilities, each with the subject and the clock dropped from its signature.access.object(kind)binds the other axis. The handle names one object kind once and returnscan,canMany,canFieldsandreadsObject, each of which still takes the subject first.
That policy declares report and listing, so it offers object('report') and
object('listing'). A policy chaining four .for() calls offers four.
Where to go next
- Why was this refused?. The explanation fields, and what a UI does with each.
- What can they do at all?. The no-object question.
- Caveats and pitfalls. Start with what
allowedmeans.