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 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
canManydecides the array in one call. - A question has fields, so a decision names which you may write and
pickAllowedFieldsapplies 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' }).; // -> falsepolicy<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:
.allowgrants the action when the rule’s conditions hold..denyrefuses 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
askedBytos2, and the allow rule compares two identifiers that differ, which isno-rule-matched. - Change
statustolocked, and the deny rule matches, which isdeniedwhatever 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.
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.
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.
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
- Caveats and pitfalls. The mistakes this API makes easy, and what to write in their place.
- Rules that another service wrote. The schema, foreign documents, versioning.
- Can this user do this?.
canMany, and asking about a list. - Which fields may they write?. The whole field axis, one task at a time.