What @evanion/acl does not do
@evanion/acl 0.1.0 and its patch releases up to 0.1.2, the version on npm. Values on this page were produced by running the source at 40b8f84, a later commit than the 0.1.0 tag, and are not re-executed.
@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:
- how soon a revocation reaches a consumer
- a relationship across two rows
- which subjects can take an action
- a decision record another consumer wrote
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 capabilitiesanswersstale-contractfor every entryreadsObjectignores 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 asks | What answers it |
|---|---|
| the control’s definition on a given date | the matrix at the version deployed on that date |
| evidence that a change was authorised | the git history: an author, a reviewer, a timestamp, a diff |
| evidence that the control operated | the decision records of the service that owns the row |
| a list of who held access | nothing 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.
| Refused | The property it breaks |
|---|---|
| a remote decision endpoint, a sidecar or a decision-point service | every property of a local decision becomes conditional on the network |
| relationship traversal or a graph walk | the document carries no graph, and fetching one during a decision reaches the network |
| a fetcher, a poller or a loader taking a URL | a caller constructing inside a request handler puts a network call on the decision path |
| a push revocation channel | a consumer that missed a push believes it is fresh, and maxStale still bounds the window anyway |
| a decision cache | a cache adds a key to get wrong and a second staleness window inside the process |
| a decision sink, a log transport or an audit shipper | the application’s logging stack already holds retention, redaction and a destination |
| a reverse index over subjects | it needs the subject population, so the library would hold a copy of the identity directory |
| an administration interface that writes the matrix | the 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 budget | expiryOf 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.