Skip to Content
AuthorizationRules that read the object

Rules that read the object

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.

Every example on this page is a region of libs/acl/README.md and runs under nx test @evanion/acl.

The concept. An object.* condition reads both the subject and the instance the caller passed to can. What you get. A policy that compares object.askedBy with subject.id and tells your question from Jo’s. Why you want it. An ownership test written in the handler states the rule a second time, and the two copies disagree the first time one of them changes. How the library gets you there. One object.* condition brings the typed builder, projections, the unevaluable answer and the field axis with it.

What changes when a condition names an object path. A subject-only condition reads the subject the server resolved whole, so it either holds or fails. A condition naming an object.* path also reads the instance the caller passed, and an instance that does not carry the path leaves the condition unevaluable until a refetch supplies it.

What an acl object condition reads

A rule that names an object.* path reads the instance in hand, so a policy can tell your question on the Brass: Birmingham listing from Jo’s. You and Jo are both customers with exactly the same permissions, and only the question’s askedBy tells you apart.

The policy then knows whose question is whose, and the save writes only the fields you are allowed to change.

You write the check by hand, as an if in the handler, next to the code that does the work. Six months later there are forty of them, three are subtly different, and the bulk close job never had one at all.

One condition naming object.askedBy brings four things with it.

Four things you now need

You need four things once a rule reads the question:

  • You name a path into a question, so the typed builder makes a typo a compile error.
  • A question may be half-loaded, so a decision gains a fourth answer, unevaluable, and you refetch and ask again.
  • A page shows forty questions, so canMany decides the array in one call.
  • A question has fields, so a decision names which you may write and pickAllowedFields applies it.

Naming the question, so acl can check the paths

object.askedBy is a path string, and a path string takes typos. Write object.askedBv with a v and the matrix builds, the tests pass, the permission never says yes to anybody, and you find out from a bug report.

Name the shape once and the compiler holds you to it:

import { } from '@evanion/acl'; import type { } from '@evanion/acl'; type = { : string; : string[] }; type = { : string; : 'open' | 'locked' }; type = { : string }; // One policy, every object kind the shop has. Each `.for()` adds a kind and // keeps the ones before it, so `access` answers for questions and listings // alike and there is one document to ship. const = < , { : ; : }, { : | 'hide' } >() .('question', () => .( 'update', .( .('object.askedBy', 'subject.id'), .('subject.roles', 'bookseller'), ), ) .('hide', .('subject.roles', 'bookseller')) .('delete', .('object.status', 'locked')), ) .('listing', () => .('update', .('object.sellerId', 'subject.id')), ) .(); const = { : 's1', : [] }; const = { : 's1', : 'open' } as ; .(, 'question', 'update', ).; // -> true .(, 'listing', 'update', { : 's1' }).; // -> true .(, 'listing', 'update', { : 's2' }).; // -> false

policy<Subject, Objects, Verbs>() names the subject, the object kinds, and any verbs beyond the default four. Each .for() hands the condition helpers to a block. Chain one per kind your app has, so one policy covers the whole app.

The block declares two kinds of rule:

  • .allow grants the action when the rule’s conditions hold.
  • .deny refuses it, so the block states once that nobody may delete a question the shop has locked, and a deny that matches outranks every allow that matched with it.

No permission gates another: a rule that hide shares with update is written into hide, where a reader of hide sees it.

A helper reads a string beginning subject. or object. as a path into that thing, and any other string as a plain value. So p.eq('object.status', 'locked') compares the status against the text locked, and p.eq('object.askedBy', 'subject.idd') refuses to compile, with the path you mistyped in the message.

build() flattens the blocks to a plain matrix, so JSON.stringify(access.matrix) emits the same JSON envelope the subject-only policy flattened to. The types live in your editor and never reach the document.

The four answers an acl decision can give

A decision has four answers, and the fourth arrives when the question in hand does not carry a path the rules read.

The listing page loads forty questions and selects from each only the fields it renders. The allow rule reads object.askedBy and the deny rule reads object.status, and a question carrying neither field settles as neither yes nor no.

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']

Three of them you have already met. Nothing matched, so no. An allow rule matched, so yes. A deny matched, so no, and the deny wins.

The fourth is new. The engine could not tell, so it answers unevaluable and missing names the paths it could not read. Fetch those fields and ask again.

`unevaluable` is not `denied`

allowed is already false, so nothing leaks. But the answer is repairable: fetch what missing names and ask once more. A UI that renders it as forbidden hides something the user can have. Why was this refused? has both halves.

Watching an acl decision move between the four answers

The control below runs the policy above. A field’s checkbox says whether the query selected it and its select says what it holds. A field the query left out never reaches the engine.

The control opens on a projection that selected neither field, so the decision is unevaluable and missing names both paths. Three moves take it elsewhere:

  • Select both fields, and your own draft question decides allow.
  • Change askedBy to s2, and the allow rule compares two identifiers that differ, which is no-rule-matched.
  • Change status to locked, and the deny rule matches, which is denied whatever the allow side said.

When an acl decision returns to unevaluable

A decision returns to unevaluable only where the data it does have settles nothing. Clear status on your own draft question and the deny side has nothing to read, so missing names object.status. Clear it on Jo’s question and the answer is no-rule-matched, because the allow rule already failed on data the engine did have. Refetch selects exactly what missing named and asks again.

What the query selected

The allow rule reads object.askedBy and the deny rule reads object.status. A field the query left out never reaches the engine.

The question in hand
{}

Selects object.status and object.askedBy and asks again.

access.can({"id":"s1"}, 'question', 'update', {})

decision

allowed
false
reason
unevaluable
rule
#0
missing
object.status, object.askedBy

What that answer is

The engine could not read a path a rule names. Fetch what missing names and ask again.

allowed false, reason unevaluable, missing object.status and object.askedBy. The engine could not read a path a rule names. Fetch what missing names and ask again.

Asking an acl decision why

A decision carries reason, rule and missing beside allowed, and those three say which of the four answers you got.

All three are output only. Build an error message from them, log them, show them in a tooltip. Never branch on them: gate on allowed and nothing else. A handler that checks reason !== 'denied' grants everything the engine refused.

Which fields of a question acl lets you write

canFields decides the action and every field of the write in one pass, so a form knows which inputs to render. You may edit your own question, and the shop’s lock on status is not yours to change:

import { , } from '@evanion/acl'; const = < { : string }, { : { : string; : string; : string } } >() .('question', () => .('update', .('object.askedBy', 'subject.id')) .(['*', '!status']), ) .(); const = { : 'c1', : 'In stock?', : 'open' }; const = { : 'Wingspan in stock?', : 'locked' }; const = .( { : 'c1' }, 'question', 'update', , 'write', , ); .['status']; // -> 'denied' .((, )); // -> '{"body":"Wingspan in stock?"}'

* means every field, and !status takes one back out.

The fifth argument is the axis. 'write' asks which fields you may set and takes the proposed question after it. 'read' asks which you may see and takes nothing more. The call above names update as the action and write as the axis, and they are different arguments.

The map covers every field on the question, every field your rules name, and on a write every key the caller sent you, including one the question has never had.

Applying an acl field map with pickAllowedFields

pickAllowedFields takes the decision and the proposed question and returns the keys that decided allowed.

The guarded save. canFields decides the action and every key of the proposed write in one pass, and each key comes back allowed, denied or unevaluable. pickAllowedFields keeps the allowed keys and drops the other two states, so only those keys reach the ORM.

Pass what it returns to the ORM. Pass only that.

Never filter the map yourself. Never hand-filter the field map is the mistake this API makes easiest: the obvious loop keeps every field that is not denied, and that keeps the unevaluable ones too.

Where to go next

Last updated on