Skip to Content
AuthorizationCaveats & Pitfalls

Caveats and pitfalls

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 behaviour on this page was executed against libs/acl/src before it was written down, including the two gaps at the bottom.

Every decision @evanion/acl returns carries one gate, allowed, and several explanation fields that gate nothing. Each heading below names a mistake this API invites and the form that holds, and this repository has made several of them.

The names these fences share

Every fence on this page reuses one set of names:

NameWhat it holds
accessthe evaluator policy().build() or hydratePolicy(matrix) handed back
subjectthe bookseller the server resolved from the session
decisionwhat can or canFields answered
currentthe row as it stands
projectiona partial of current
proposedthe write a form submitted
pthe block parameter .for() passes its block, carrying allow, deny, fields and the condition helpers

Reading a decision

Gate on allowed, not on reason

if (decision.reason !== 'denied') proceed(); // wrong if (decision.allowed) proceed(); // right

reason is output only, and its seven values do not split into two buckets. Each of these refuses, and the first form proceeds through every one:

  • no-rule-matched
  • unknown-action
  • unevaluable
  • unusable-clock
  • stale-contract

unevaluable is not denied

if (!decision.allowed) return <ReadOnly />; // loses a repairable answer

unevaluable means the engine could not reach the decision with the data it was handed, and missing names what to fetch.

  • allowed is already false, so a server that refuses on it is safe.
  • A UI that refuses on it hides something the user can have.
  • Render the control as pending, fetch what missing names, and ask once more.
  • A caller who reads unevaluable as permission has a hole.

An unevaluable that survives a refetch is a mistyped field name in the document. A schema turns that class into an UnknownFieldError at construction. The refusal fields has both halves.

A key the document does not carry is absent, not false

items below is the menu the app is about to draw, each entry carrying the permission key it needs.

const caps = access.capabilities(subject); items.filter((item) => !caps[item.key]?.allowed); // shows every unknown key items.filter((item) => caps[item.key]?.allowed === true); // right

A capability map holds an entry per permission in the document.

  • A key the document does not carry reads undefined, and negating undefined?.allowed is true.
  • A menu written the first way grows an item whenever somebody renames a permission.

Writing fields

Never hand-filter the field map

// wrong Object.entries(proposed).filter(([key]) => decision.fields[key] !== 'denied'); // right pickAllowedFields(decision, proposed);

FieldState has three values. transitions is a state machine over a field’s current value, and a field whose config the object did not carry that value for comes back unevaluable. The first form writes a value the decision declined to approve, in two ways:

  • a key that decided unevaluable, since 'unevaluable' !== 'denied' is true
  • a key the decision does not carry at all, since undefined !== 'denied' is true

pickAllowedFields keeps the keys that decided allowed and withholds the rest.

A name absent from fields is not a denial

const fd = access.canFields(subject, 'question', 'update', projection, 'read'); fd.fields['status']; // undefined: the projection did not carry it

canFields keys the map by the union of four sets:

  • the object’s own keys
  • the proposed write’s keys
  • the rule’s field names
  • the names in the allow-list

Hand canFields a projection and the map shrinks. A name in the map is a decision; a name missing from it is a question nobody asked. A form rendered from fields draws fewer inputs, and pickAllowedFields writes only keys that decided allowed, so the default direction is the safe one.

Read allowed, not action.allowed

const fd = access.canFields( subject, 'user', 'update', current, 'write', proposed, ); fd.action.allowed; // true: the action is permitted fd.allowed; // false: a field in this write is not

canFields returns two booleans named allowed, one nested inside the other, and they disagree exactly when the narrowing is doing its job.

  • fd.allowed composes both: the action is allowed and every decided field is allowed.
  • fd.action.allowed is the action alone.

Do not iterate fields without checking allowed first

canFields fills the field maps in whatever the action decides.

  • A subject refused the action still gets a complete map of which fields would be editable once the action is unblocked.
  • A caller who reads that map as the gate skips the action entirely.
  • The map looks identical either way.

A targets or transitions config is not an allow-list

p.fields({ status: { transitions: { draft: ['published'] } } }); // every other key is open p.fields({ fields: ['*', '!id'], status: { transitions: { draft: ['published'] } }, });

A per-field config restricts the field it names and no other.

  • With no fields list, every unnamed key is writable.
  • A caller who reads the config as the writable-field list leaves the rest of the row open.

Authoring a policy

A condition may only read fields the subject cannot write

p.allow('read', p.eq('object.sharedWith', 'subject.id')); // self-authorizing p.allow('read', p.eq('object.ownerId', 'subject.id')); // set at creation

If the subject can write sharedWith, they authorize themselves for the document. The engine cannot see this: it does not know which object fields the subject can write, and the write that opens the door is a different action from the read. Keep the field out of every write path the rule guards, or key the rule on something out of reach: ownership set at creation, or a field only the server writes.

The matrix is public in its names, not only its values

bypass-kyc as an action name, or internal-fraud-reviewer as a role string, is disclosed the moment a page loads. The document reaches the client in full, carrying:

  • every object kind and every action
  • every role
  • every field name, including the ones the API never returns
  • every time window

Send it anyway. Security must not rest on the document staying secret, and here it does not. Name things as if a reader has them open, and keep secrets and server-only predicates out of the document.

Around the call

The subject is whatever you hand it

can(subject, …) authorizes the bag it receives and has no channel to ask where that bag came from. A subject derived from any of these gets the attacker’s claimed identity faithfully authorized:

  • a header
  • a query parameter
  • an unverified token body
  • a field the client posted

No evaluator can fix this confused deputy. Resolve the subject from a verified session or token, server-side, before it reaches the call.

A browser decision enforces nothing, and every layer decides for itself

In a browser can toggles what the user sees. The control disappears; the data behind it stays, and so does the request it would have sent. The call has the same signature and return type in both places, nothing in the types separates authoritative from advisory, and the runtime decides.

A gateway or a backend-for-frontend layer that already allowed the request does not excuse the service behind it, because a caller reaches that service directly whenever it wants to. The second evaluation is a local function call over a frozen object, which makes “check again” a rule a team can keep.

Re-check inside the transaction

A decision describes the snapshot it was given. Between can returning true and the write landing:

  • the object can change owner
  • the subject can lose the role
  • the time window can close

The library carries no freshness token and cannot see the gap.

The gap a decision cannot see. can answers over the snapshot it was handed; the object can change owner, the subject can lose the role and the window can close before the write runs. Re-reading and re-checking inside the transaction closes the gap, and so does a conditional predicate on the write.

Re-read the object and re-check inside the transaction, or write with a conditional predicate that fails when the state has moved.

Two gaps with no defence

Neither gap below is guarded, and a caller who knows about them avoids both.

A clock the caller supplies is the clock the windows move with

import { } from '@evanion/acl'; const = < { : string }, { : { : string } }, { : 'buy' } >() .('sale', () => .('buy', .('now', '2026-01-01T00:00:00Z'))) .(); const = .( .({ : new ('2026-06-01T00:00:00Z') }), ) as { : string }; const = .({ : 's1' }, 'sale', 'buy', , .); .; // -> true const = .('2025-06-01T00:00:00Z'); const = .({ : 's1' }, 'sale', 'buy', , ); .; // -> false

Decide deliberately whether to supply now at all. It slides every window in the matrix, and the engine has no channel to ask where the instant came from. Pass it only to make a server render and the client’s first render agree, and resolve it server-side. A now from a request body hands over every time window there is.

A clock that does not parse is guarded. Each of these answers { allowed: false, reason: 'unusable-clock' } on both sides of the permission, so a time-gated deny goes on denying:

  • null
  • NaN
  • an Invalid Date
  • a string that is not a date

The subject and object bags are read live

let reads = 0; const lazy = { get status() { return reads++ === 0 ? 'draft' : 'locked'; // an ORM row, a refilling cache }, }; access.can(subject, 'question', 'update', lazy); // -> allowed: true access.can(subject, 'question', 'update', { status: 'locked' }); // -> reason: 'denied'

Conditions read subject and object field by field as the decision walks the rules, and the engine reads the deny side before the allow side. A bag whose properties are accessors can answer the two sides differently and pass the deny it should have matched:

  • an ORM row
  • a lazy proxy
  • a memoised getter over a cache that can refill

The engine copies neither the subject nor the object, because they are the app’s data, and the matrix it evaluates is a frozen deep copy for this reason. Pass plain, already-resolved objects.

Where this comes from

The package holds itself to a register, one row per class of attack, each naming the test that proves it under nx test @evanion/acl. The security contract covers the same material in prose.

Last updated on