One policy behind a screen
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. A component calls access.can once per action it might draw,
over the document the app fetched.
What you get. An action bar carrying no role test of its own.
Why you want it. A component that tests the role itself states the rule a
second time, and the handler it posts to is the copy somebody tightens.
How the library gets you there. The bar reads allowed to decide whether to
draw a button, and reason for the wording when it does not.
The action bar this page builds
Baize’s Brass: Birmingham listing draws an action bar of three buttons, Review, Edit and Publish. Four policy statements decide what that bar draws for whoever is signed in:
- Jo the customer sees one button.
- A bookseller sees two.
- The owner sees three.
- Nobody sees Edit once the listing is published.
The bar holds that list of actions and no if. Put the rule in the component
and you have written it twice, once here and once in the handler the button
posts to. Somebody tightens the handler, the button stays lit, and a bookseller
presses Edit on a published listing and gets a 403 with no explanation.
The storefront fetched the matrix and hydrated it in Rules that another service
wrote. The bar calls access.can on that evaluator once per
action and draws from allowed, with reason as the wording of a disabled
button.
The shop’s listing policy, in four statements
listing is the object kind this screen draws, and the fetched document carries
four statements for it. The storefront never runs the builder call below; the
producer ran it:
- Jo reviews a listing, and so does everyone else who is signed in.
- A bookseller edits a listing.
- The owner publishes a listing.
- A published listing is closed to edits whoever asks.
import { } from '@evanion/acl';
/** Who is signed in. `role` is what the policy reads. */
interface Shopper {
: 'customer' | 'bookseller' | 'owner';
}
/** A game listing in the shop. A status, because that is all a rule reads. */
interface Listing {
: 'draft' | 'published';
}
const = <
Shopper,
{ : Listing },
{ : 'review' | 'edit' | 'publish' }
>()
.('listing', () =>
.('review', .)
.('edit', .('subject.role', ['bookseller', 'owner']))
.('publish', .('subject.role', ['owner']))
.('edit', .('object.status', 'published')),
)
.();
const : Shopper = { : 'bookseller' };
const : Listing = { : 'draft' };
.(, 'listing', 'review', ).; // -> true
.(, 'listing', 'edit', ).; // -> true
.(, 'listing', 'publish', ).; // -> 'no-rule-matched'
.(, 'listing', 'edit', { : 'published' }).; // -> 'denied'The .for('listing', …) block is one of however many kinds the shop’s policy
holds, and a shop that also sells events chains .for('event', …) beside it.
Two condition helpers are new in that block:
p.alwaysis the condition that holds for anybody, soreviewis granted to whoever is signed in.p.intests one subject value against a list written in the rule, and thisShoppercarriesroleas a single string.
The report policy on rules that read the subject carried roles
as an array and read it with p.contains, which runs the test the other way
round: one value written in the rule, against a list held on the subject.
The typed builder binds Listing to the key listing, so it checks
object.status in the deny against the interface as it is written. That binding
turns a misspelled path into a compile error, and you met it first where rules
read the object. Once the builder’s types have gone, the
schema on the document holds a foreign producer to the same paths.
What the action bar draws for each shopper
Baize’s listing action bar draws from the four statements in the shop’s listing
policy and nothing else. The bar renders one button per action and calls
access.can once for each: allowed says whether to draw the button, and
reason is the wording when it draws the button greyed out.
Every answer the four statements give, per shopper and per listing status:
| Signed in as | Listing status | review | edit | publish |
|---|---|---|---|---|
| customer | draft | allow | no-rule-matched | no-rule-matched |
| bookseller | draft | allow | allow | no-rule-matched |
| owner | draft | allow | allow | allow |
| bookseller | published | allow | denied | no-rule-matched |
| owner | published | allow | denied | allow |
Add a fourth control by writing one more statement in the policy and one more row in the list of actions. Never add it as a branch in the component, which is a rule written somewhere the policy cannot see.
A status change moves the action bar without touching the policy
Press Publish and object.status becomes published. The deny in the shop’s
listing policy now matches, so edit comes back denied. The listing’s action
bar draws no Edit button for any role, the bookseller’s and the owner’s alike,
because a deny outranks a matching allow.
The service that published this document evaluates the same four statements before it writes the status, so the screen and the write agree by reading one document.
Edit the policy and watch the action bar redraw
The demo below draws the listing’s action bar from the shop’s four listing
statements. Sign in as each of the three shoppers and the bar rebuilds itself
from access.can. Press a role inside a grant to strike it out, and the control
that grant produced leaves while you are looking at it. Press Publish and the
deny matches, so neither the bookseller nor the owner is offered Edit.
Brass: Birminghamdraft
Network building on the canals and railways of the Midlands, two to four players, about two hours. Complexity 3.9 of 5. A copy is on the shelf and another sits on table three most evenings.
- Jo VainioPlayed it twice at the back tables before buying. Worth the shelf space.
access
const access = policy<Shopper, ShopObjects, ShopVerbs>()
.for('listing', (p) => p .allow('review', p.always) .allow('edit',
p.in('subject.role', [, , ])) .allow('publish',
p.in('subject.role', [, , ])) .deny('edit', p.eq('object.status', 'published')), )
.build();The policy in the panel is built by the same policy() call the region above
runs, and the buttons are Decision.allowed and nothing else.
The browser’s copy of the shop’s policy decides nothing
The decisions the listing’s action bar renders are convenience. The same can
runs again in the shop’s server runtime, over a subject that runtime resolved
itself, before any listing is edited or published. A screen that hid the button
does not excuse the handler the button posts to. The security contract is the
long form, and Platforms is where the second call sits on each
stack.
Where a refused control gets its wording
The listing’s action bar removes a control it may not draw. A bar that greys the
control out instead has to say why, and Decision carries reason, rule and
missing beside allowed for that. What each key means, and which of them are
repairable, is Why was this
refused?; the shape itself is in the API
reference.