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:
| Name | What it holds |
|---|---|
access | the evaluator policy().build() or hydratePolicy(matrix) handed back |
subject | the bookseller the server resolved from the session |
decision | what can or canFields answered |
current | the row as it stands |
projection | a partial of current |
proposed | the write a form submitted |
p | the 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(); // rightreason 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-matchedunknown-actionunevaluableunusable-clockstale-contract
unevaluable is not denied
if (!decision.allowed) return <ReadOnly />; // loses a repairable answerunevaluable means the engine could not reach the decision with the data it was
handed, and missing names what to fetch.
allowedis alreadyfalse, 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
missingnames, and ask once more. - A caller who reads
unevaluableas 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); // rightA capability map holds an entry per permission in the document.
- A key the document does not carry reads
undefined, and negatingundefined?.allowedistrue. - 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'istrue - a key the decision does not carry at all, since
undefined !== 'denied'istrue
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 itcanFields 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 notcanFields returns two booleans named allowed, one nested inside the other,
and they disagree exactly when the narrowing is doing its job.
fd.allowedcomposes both: the action is allowed and every decided field is allowed.fd.action.allowedis 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
fieldslist, 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 creationIf 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.
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', , );
.; // -> falseDecide 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:
nullNaN- 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.