Skip to Content
AuthorizationSecurity Contract

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 runsWhat the answer does
a React Router 8 or Next.js server runtimeenforces the decision, and guards the write
a Node serviceenforces the decision, and guards the write
the server side of an API boundaryenforces the decision, and guards the write
a browsertoggles controls only, and enforces nothing
  • can keeps 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.

One matrix, evaluated once per process. The browser's answer draws the interface and the gateway's answer does not excuse the service, so the service's own answer guards the write. A caller reaches the service directly whenever it wants to.
  • 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-kyc or a role named internal-fraud-reviewer is 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: pickAllowedFields for a write, allowed for 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.

nowWhat the engine readsWhat a permission with a time window answers
omittedthe wall clock of the processthe window as that process’s clock sees it
an instant the caller suppliesthat instantthe window as the caller’s instant places it
a browser wall clockthe clock the subject controlsa closed window the subject re-opens by setting it back
null, NaN, an Invalid Date, a string that is not a datenothing 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'
Not executed — nothing here runs it
  • A now that reaches can from a client payload (a request body, a query string, anything the browser sent) hands the client every time window in the matrix.
  • Pass now only 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/after boundary 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 now is 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
Do not write this
  • 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 }
Not executed — nothing here runs it
  • When an owner revokes a permission, the client keeps granting it until it refetches, and it has no way to notice.
  • access.version detects 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.version undefined, where a !== decides nothing.
  • The server never depends on a client’s copy; it evaluates its own.
Last updated on