Skip to Content
AuthorizationRules that read the subject

Rules that read the subject

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.

Inside this repository the package resolves from source like any other workspace package, so the example below runs as written.

The concept. A subject-only rule reads the subject the server resolved and nothing else. What you get. Every permission answered with no database read in front of the call. Why you want it. A role test written by hand in each handler answers differently per handler once somebody edits one of them. How the library gets you there. policy() writes the matrix at startup, and can reads it on every question.

What a subject-only policy covers

A subject-only rule reads the subject the server resolved and nothing else, so can answers it with no database read in front of it. Baize’s stock report carries two such rules: any bookseller may read the report, and only the owner may export it.

The matrix holds both permissions, the server resolves the subject once per request, and one call answers yes or no for any permission in the document.

policy() writes the matrix and hands back an access object over it. can answers one question, authorize binds the subject, and capabilities answers the whole document at once.

You write the twentieth role check by hand and four handlers now disagree: one tests roles.includes('owner'), another tests user.isOwner, a third was copied before the role was renamed, and the export endpoint tests nothing at all.

Install acl

@evanion/acl installs as one package and brings nothing with it:

npm install @evanion/acl

ESM only, Node 20 or newer. No framework reaches the import graph.

Write the acl policy

policy() builds a policy from three calls:

  • policy<Subject, Objects>() names the subject type and the object kinds.
  • .for(key, block) writes one kind’s rules.
  • .build() hands back the evaluator.

A block holds allow rules and a rule holds conditions. The engine ANDs the conditions, so a rule matches when every condition in it holds. p is the condition helper the block hands you, and p.contains(path, value) reads the array at subject.roles and asks whether the role is in it. Every condition below reads the subject, so capabilities answers the whole document at once:

import { } from '@evanion/acl'; import type { } from '@evanion/acl'; type = { : string; : string[] }; // One policy, every object kind the app has. A `.for()` per kind, and the // permissions they flatten to live in the same flat list. const = < , { : { : string }; : { : string } }, { : | 'export' } >() .('report', () => .('read', .('subject.roles', 'bookseller')) .('export', .('subject.roles', 'owner')), ) .('listing', () => .('read', .('subject.roles', 'owner'))) .(); const = .({ : 'u1', : ['bookseller'] }); .(); // -> ['report.read', 'report.export', 'listing.read'] ['report.read']?.; // -> true ['report.export']?.; // -> 'no-rule-matched' ['listing.read']?.; // -> 'no-rule-matched'

One policy holds every object kind your app has. Chain a .for() per kind, and do not build a policy per resource.

Nothing is allowed without an allow rule that matched. report.export carries one such rule, and the bookseller’s roles do not satisfy it, so the decision is no-rule-matched. Default deny is the floor of the engine and you configure nothing to get it.

What the engine decides for a permission carrying allow rules only, which is every permission in the policy above. The engine reads those rules against the subject the caller passed. One rule whose conditions all hold answers allow; no such rule answers no-rule-matched, which is the default deny this policy never states.

access.matrix is what the blocks flattened to: a JSON envelope over a flat list of permissions, each carrying a key of exactly `${object}.${action}`. That document is what crosses to a client, where hydratePolicy turns it back into an evaluator.

Ask acl a question

can takes the subject, the object kind (report) and the action (read), and returns one decision:

const decision = access.can( { id: 'u1', roles: ['bookseller'] }, 'report', 'read', ); // { key: 'report.read', allowed: true, reason: 'allow', rule: '#0' }

allowed is the answer. Gate on it and nothing else. reason is output only, and a handler that branches on the reason string grants what the engine refused.

Bind the subject to one acl handle

authorize takes the subject once and returns a handle that asks many questions about it, over the same report-and-listing policy:

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 handle carries can, canMany, canFields and capabilities with the subject dropped from each signature, which makes the guarded call the shortest one to write. That is all the library can do about complete mediation.

This decision is authoritative only where it is made

The same call in a browser toggles what a user sees and enforces nothing. Hiding a button hides the button, not the request the button would have sent. Read the security contract before this reaches production.

Where subject-only rules stop

The engine settles every permission in a subject-only policy, and two things follow:

  • The server resolves the subject whole before the call, so every permission answers true or false and nothing answers unevaluable.
  • Nothing has to be fetched to ask a question, so can takes three arguments and capabilities answers the whole matrix at once.

Write one rule that reads the object, the customer who asked may edit their own question, and both break at once. Rules that read the object picks up there.

Where to go next

Last updated on