Skip to Content
AuthorizationCan this user do this?

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', }); .; // -> true

The 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:

One call, two consumers. The handler gates on allowed and reads nothing else; the Edit button reads reason, rule and missing to say why it is greyed out. Both read the same decision, so the two cannot disagree.

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. now is parsed into a single epoch for the list. A loop of can with no now reads Date.now() per row, so a time window can close partway down the page.
  • The object-kind lookup. Each can scans the permission list to check the object kind exists. canMany scans 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:

  • authorize binds the subject and one instant. With no now the instant is the moment you call it, and authorize(subject, { now }) names the instant yourself. The handle returns can, canMany, canFields and capabilities, 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 returns can, canMany, canFields and readsObject, 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

Last updated on