Skip to Content
AuthorizationWhat this does not do

Unreleased changes

These pages document main, which has changes 0.1.2 does not. npm install @evanion/acl gives you 0.1.2, documented here. Values on these pages are produced by running the current source on every build.

What @evanion/acl does not do

@evanion/acl decides from a frozen JSON document and the subject and object bags a caller already holds. Four questions sit outside that model, so no release closes one, and each section names the tool that does:

Two further sections follow:

A revocation reaches a consumer within maxStale

expiryOf (libs/acl/src/hydrate-policy.ts:197-214) settles the instant past which every key answers stale-contract, at fetchedAt + min(matrix.maxStale, options.maxStale). A consumer holding a document fetched before the owner edited it goes on deciding from the old rules until it revalidates. hydratePolicy and parseMatrix both take a consumer’s own maxStale, which shortens that bound and never extends it, so the shorter of the two budgets is the ceiling on revocation latency.

With maxStale: 1000, fetchedAt: 0 and a settled clock of 5000:

import { } from '@evanion/acl'; const = ( { : 'orders@7', : 1000, : [ { : 'question.update', : 'question', : 'update', : [ { : [{ : 'object.askedBy', : 'eq', : 'subject.id' }] }, ], }, ], }, { : 0 }, ); const = { : 's1' }; const = { : 's1' }; const = 5000; .(, 'question', 'update', , ).; // -> 'stale-contract' .(, 'question', 'delete', , ).; // -> 'stale-contract' .(, )['question.update']?.; // -> 'stale-contract' .('question', 'update'); // -> true
  • a key the document carries answers stale-contract
  • a key the document does not carry answers stale-contract
  • capabilities answers stale-contract for every entry
  • readsObject ignores the expiry and goes on answering, because it states a fact about the document and none about the present

To make a revocation take effect inside one second, state maxStale: 1000 and have every consumer revalidate at that rate. Two enterprise cases separate at this point, and a deployment that confuses them sets the budget far lower than it needs. A terminated employee is revoked at the identity provider, which reaches the subject: subject.roles stops carrying the role, and every consumer resolving a fresh subject refuses at once whatever the document’s age. Revocation through the document is for a change of rule. An emergency grant written into the document carries the same bound in the worse direction. Put the emergency role on the subject and the condition in the document, so the document names the role it honours and never the person.

A relationship across two rows has no expression in a matrix

“Is this question in a shop this bookseller’s team owns” states a hop, and a matrix condition reads one field of one scope. readPath (libs/acl/src/conditions.ts:76-92) splits a path at its first dot and reads the remainder as a single own key off the subject or object bag. pathFault (libs/acl/src/validate.ts:58-74) refuses the deeper form at construction, so a policy naming one throws before it decides anything:

import { } from '@evanion/acl'; import type { Matrix } from '@evanion/acl'; // `object.shop` is the shop, and `.ownerId` a field of that second row. const : Matrix = { : 'v1', : [ { : 'question.edit', : 'question', : 'edit', : [ { : [ { : 'object.shop.ownerId', : 'eq', : 'subject.id' }, ], }, ], }, ], }; let = ''; try { (); } catch () { = `${( as Error).}: ${( as Error).}`; } ; // -> 'InvalidConditionError: permission "question.edit": condition rules[0].when[0] on "object.shop.ownerId" nests below its scope: a path reads one field of a scope, so at most one dot is resolvable'

ObjectSchema.relations (libs/acl/src/types.ts:176-183) declares which kinds one kind points at, one hop and no nesting. A condition cannot compare a relation, because a relation is no value. The field is declared for the consumers that resolve the hop themselves.

A hop needs the graph. A matrix carries no graph, and a load mid-decision puts a network call on the path @evanion/acl exists to keep clear of one. A caller that has already resolved the hop passes the result as a flat field, and object.teamId on a loaded row is an ordinary condition that decides correctly. An answer that depends on walking a hierarchy the caller does not hold belongs to a Zanzibar implementation, where a relationship tuple is the primitive and the engine walks the graph. Such a system runs as a service over a database, and each check is a network call.

No @evanion/acl call lists the subjects who can take an action

capabilities answers for one subject at a time, and no entry point of @evanion/acl ever receives a subject population. An access review asks who can approve a refund; a matrix answers that a subject whose roles contains approver and whose tenantId equals the order’s can. Converting the second answer into the first needs the list of subjects, which a matrix does not carry.

Attribute-based access control (ABAC) carries this limit generally, so it belongs to the model and not to this implementation. National Institute of Standards and Technology (NIST) Special Publication (SP) 800-162 §3.1.2.3 records two things about it:

  • It calls the review of who holds access before any request is made a before-the-fact audit, often necessary for demonstrating compliance, and states that “An ABAC system may not lend itself well to conducting these audits efficiently”.
  • Determining who can reach an object may require simulating the request for every known subject.

The join belongs to the identity directory, and what a matrix contributes to it is a machine-readable statement of the conditions per permission.

The inverse question is answerable and is the stronger evidence. What gated order.refund on 3 March is the document at the version deployed on 3 March.

Every @evanion/acl consumer keeps its own decision records

can runs inside each consumer process. Each consumer writes its own decision records, and no consumer holds another’s. A call site that never calls can writes no record, so an empty log does not show that nobody read the row. Count the instrumented call sites yourself: @evanion/acl counts none of them.

NIST SP 800-53 AU-2 asks an organization for “a rationale for why the event types selected for logging are deemed to be adequate”. Write that rationale yourself, naming which call sites are instrumented and why the uninstrumented ones do not matter.

Which decision record is the @evanion/acl access record

A consumer’s decision records are user-interface evidence, and the records of the service that owns the row are access evidence. Every layer re-evaluates and no layer takes an earlier layer’s answer as an assertion. A browser’s can decides what the screen draws and never what the write is permitted to do, so an audit presenting a browser’s decision log as an access record presents the wrong artifact. The security contract states where a decision binds.

What a compliance reviewer asksWhat answers it
the control’s definition on a given datethe matrix at the version deployed on that date
evidence that a change was authorisedthe git history: an author, a reviewer, a timestamp, a diff
evidence that the control operatedthe decision records of the service that owns the row
a list of who held accessnothing here; the subject population is the directory’s

The nine features @evanion/acl will not add

Nine features are refused, each because of the property it would break. The list is a decision and holds until a spec changes it, so plan a deployment around the refusals.

RefusedThe property it breaks
a remote decision endpoint, a sidecar or a decision-point serviceevery property of a local decision becomes conditional on the network
relationship traversal or a graph walkthe document carries no graph, and fetching one during a decision reaches the network
a fetcher, a poller or a loader taking a URLa caller constructing inside a request handler puts a network call on the decision path
a push revocation channela consumer that missed a push believes it is fresh, and maxStale still bounds the window anyway
a decision cachea cache adds a key to get wrong and a second staleness window inside the process
a decision sink, a log transport or an audit shipperthe application’s logging stack already holds retention, redaction and a destination
a reverse index over subjectsit needs the subject population, so the library would hold a copy of the identity directory
an administration interface that writes the matrixthe document would change outside review, and review is the only control over an over-broad grant
a per-consumer freshness override that extends the owner’s budgetexpiryOf takes min of the two, because a consumer choosing its own bound optimises for itself

Two of the nine get asked for most: relationship traversal and the reverse index over subjects. Relationship-based systems answer both, and a half-built version here would answer neither. A consumer may still set a shorter freshness budget than the owner’s. An interface that opens a pull request against the matrix is not refused here either, because review still gates the merge.

Last updated on