Security contract
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.
In a browser, `can` decides what the user sees
The control disappears; the data behind it stays, and so does the request the control would have sent. Enforcement happens in a trusted environment, and a page that hid the button does not excuse the handler the button posts to.
can answers authoritatively in a trusted environment and advisory everywhere
else. The runtime decides which answer a caller holds.
Where a can decision binds
Where can runs | What the answer does |
|---|---|
| a React Router 8 or Next.js server runtime | enforces the decision, and guards the write |
| a Node service | enforces the decision, and guards the write |
| the server side of an API boundary | enforces the decision, and guards the write |
| a browser | toggles controls only, and enforces nothing |
cankeeps one signature and one return type in every row above.- Nothing in the types separates an authoritative answer from an advisory one.
Every layer decides for itself
Every app in the chain evaluates for itself and trusts no earlier layer.
- 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, so no evaluation waits on a network call.
- A team can keep “check again” as a rule for that reason.
The matrix is a public document
The matrix ships to the client in full, names and structure as well as values. Anyone who loads the page reads:
- every object kind and every action name
- every role string in a condition
- every field name, including the ones the API never returns
- every state machine and every time window
Ship 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, because one does: an action named
bypass-kycor a role namedinternal-fraud-revieweris disclosed the moment the page loads. - Keep secrets and server-only predicates outside the matrix, in the app layer.
Three tiers
The package holds itself to a register running under
nx test @evanion/acl, one row per class of attack, each row naming the test
that proves it and the tier it sits in:
- Tier 1, where the library refuses or throws a construction error, from mass assignment to matrix shapes that exhaust the process.
- Tier 2, where the library supplies the primitive and the consumer calls it:
pickAllowedFieldsfor a write,allowedfor a gate. - Tier 3, structurally out of scope, and every section below sits here.
Subject authenticity
can(subject, …) authorizes the bag it is handed and has no channel to ask
where that bag came from. The engine faithfully authorizes an attacker’s claimed
identity when you derive the subject from:
- 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 can.
Complete mediation
Nothing makes you call can. Each of these runs unguarded until someone guards
it:
- a new route
- a new resolver
- a background job
- an admin script
The library narrows the gap:
authorize(subject)binds once in middleware and puts the check on the path a developer already takes.- The library cannot make the unchecked path impossible.
- Your own tests are where you assert that every path is covered.
Time of check to time of use
A decision describes the snapshot it was given, and the library carries no
freshness token and cannot see the gap. Between can returning true and the
write landing:
- the object can change owner
- the subject can lose the role
- the time window can close
Close the gap either way:
- Re-read the object and re-check inside the transaction.
- Write with a conditional predicate that fails when the state has moved.
The clock a decision reads
now is a parameter, and every before/after window moves with the value the
caller passes.
now | What the engine reads | What a permission with a time window answers |
|---|---|---|
| omitted | the wall clock of the process | the window as that process’s clock sees it |
| an instant the caller supplies | that instant | the window as the caller’s instant places it |
| a browser wall clock | the clock the subject controls | a closed window the subject re-opens by setting it back |
null, NaN, an Invalid Date, a string that is not a date | nothing it can parse | { allowed: false, reason: 'unusable-clock' } |
const decision = access.can(
subject,
'sale',
'buy',
undefined,
new Date('nope'),
);
decision.allowed; // -> false
decision.reason; // -> 'unusable-clock'- A
nowthat reachescanfrom a client payload (a request body, a query string, anything the browser sent) hands the client every time window in the matrix. - Pass
nowonly to make a server render and the client’s first render agree, and resolve it on the server. - A refusal on an unusable clock decides nothing, so a deny gated on a time window goes on denying.
- A
before/afterboundary that does not parse is refused earlier still, at construction.
One page, two clocks
In a browser the wall clock belongs to the subject, and the library cannot see a subject who sets it back, because the clock is an argument.
- A role condition reads the subject a server resolved, and a time condition on the same page reads what the subject’s machine produced.
- A server passing its own
nowis unaffected.
The bag a decision reads
Conditions read subject and object live, field by field, as the decision
walks the rules. The engine reads the deny side before the allow side, and each
side reads the fields its own rules name. 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 matrix the engine evaluates is a frozen deep copy for this reason.
- The engine copies neither the subject nor the object, because they are the app’s data.
- Pass plain, already-resolved objects.
What a condition may read
A condition may read only fields the subject cannot write.
p.allow('read', p.eq('object.sharedWith', 'subject.id')); // the subject writes it
p.allow('read', p.eq('object.ownerId', 'subject.id')); // set at creation- A rule that keys on an object field within the subject’s reach is self-authorizing: the subject edits the field, then passes the check the field controls.
- The engine cannot see this, because it does not know which 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 the subject cannot reach: ownership set at creation, or a field the server alone writes.
Matrix freshness
A client holds the matrix it last fetched.
if (access.version !== fetchedVersion) {
await refetchMatrix(); // refuse the action until the copy catches up
}- When an owner revokes a permission, the client keeps granting it until it refetches, and it has no way to notice.
access.versiondetects a mismatch and resolves none. Compare it, fail closed, and force a refetch.- The comparison is a
!==, so a digest or a composite covering every input works as well as a counter. - A document that states no version leaves
access.versionundefined, where a!==decides nothing. - The server never depends on a client’s copy; it evaluates its own.