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 did editing my rules change about access?
diffMatrix(before, after) compares two matrix documents and reports who gained
or lost access, as findings a reviewer reads before the change deploys.
Reviewing an acl rule change with diffMatrix
A line-by-line diff of the matrix shows that some JSON moved, and the reviewer
then works out in their head how the deny and allow rules now combine.
diffMatrix states that result as findings. In a review, before is the
access.matrix your main branch builds and after is the one the change
builds.
diffMatrix reads the matrix as JSON. Each permission lists its allow rules
under rules and its deny rules under denyRules, the two sides of the
permission. Each rule holds a when list of conditions, and a condition is
{ field, op, value } against a literal or { field, op, path } against
another path, as the matrix reference sets out:
import { , } from '@evanion/acl';
import type { , Matrix } from '@evanion/acl';
const : = {
: 'subject.roles',
: 'contains',
: 'owner',
};
const : = {
: 'subject.roles',
: 'contains',
: 'bookseller',
};
const : = {
: 'subject.id',
: 'eq',
: 'object.sellerId',
};
const = {
: 'listing.reprice',
: 'listing',
: 'reprice',
} as ;
const : Matrix = {
: 'listings@7',
: [{ ..., : [{ : [] }] }],
};
// One branch appended: a bookseller may reprice a listing they sell.
const : Matrix = {
: 'listings@8',
: [
{
...,
: [{ : [] }, { : [, ] }],
},
],
};
const = (, );
.; // -> false
.; // -> { before: 'listings@7', after: 'listings@8' }
const = (, 'granted');
[0]?.; // -> 'listing.reprice'
[0]?.; // -> 'allow-branch-added'
[0]?..; // -> [isBookseller]
[0]?..; // -> [ownListing]
[0]?..; // -> []diffMatrix returns a MatrixDiff. findings is the list, unchanged is
true when the two documents state one policy, and version names the two
documents compared. findingsOf(diff, kind) returns one kind of finding, typed
for that kind, for a screen that renders one section of the report.
A branch is one rule. The granted finding carries the added branch whole in
when, and split for reading in groups: subject is who gained the access,
object is which rows, and window is the conditions that compare the clock,
from p.before and p.after. A condition comparing a subject path with an
object path sits in object, because it restricts rows. The groups read as one
sentence: a subject whose roles contains bookseller may now reprice a
listing whose sellerId is their own id.
The four kinds of finding an acl diff reports
Every finding carries key, and kind says which of four shapes it has:
kind | What the finding says | cause, one of |
|---|---|---|
granted | the second document grants access the first did not | permission-added, allow-branch-added, deny-branch-removed, field-list-widened |
withdrawn | the first document granted access the second does not | permission-removed, allow-branch-removed, deny-branch-added, field-list-narrowed |
undetermined | diffMatrix declines to call the change either direction | rule-edited, both-sides-changed, fields-changed |
reads-object | the object.* paths this permission reads changed | none; it carries before, after and paths |
A granted or withdrawn finding also carries when, groups and fields,
and an undetermined one carries both versions as before and after. when
is empty where the cause is field-list-widened or field-list-narrowed,
because a list of fields carries no conditions.
Why an added acl allow branch only widens access
One side of a permission matches when any of its rules matches, so a side that matched goes on matching whatever is appended to it. Appending an allow rule cannot remove an allow, and appending a deny rule cannot add one. That gives each finding a direction.
The direction is exact and the size is not. An added allow branch contains the
newly granted access, and it may also contain access the old branches already
granted, so read a granted finding as an upper bound on what widened. A
permission whose allow side and deny side both moved gets no direction at all:
each side alone moves one way, the two together do not, and diffMatrix reports
both-sides-changed with both versions attached.
Why reordering an acl document reports nothing
diffMatrix identifies a rule by the id its author gave it, or by a derived
one: the side it sits on and a hash of its conditions. Either way a rule keeps
its identity when it moves, so two documents differing only in rule order, or in
key order inside one condition, come back unchanged: true.
An author’s id is the author’s own statement that two versions are one rule. A
rule that keeps its id and changes its conditions reads as one rule edited,
and diffMatrix reports rule-edited with before and after.
What a withdrawn acl permission does to each holder
A holder is a service that runs your document, and a removed permission reaches
the two kinds of holder as two different events. A withdrawn finding whose
cause is permission-removed carries holders, a sentence for each kind:
holders.closeddescribes a holder that adopted the document withparseMatrix:listing.repriceanswersunknown-actionand refuses.holders.opendescribes a holder that built it withhydratePolicy:canthrowsUnknownPermissionErrorforlisting.reprice.
So a parseMatrix holder keeps serving pages, and a hydratePolicy holder
throws on its next call. When the removed permission was the last of its object
kind, the open sentence names UnknownObjectKeyError and says the object kind
is no longer configured in the matrix.
An acl rule change that widens nothing and breaks callers without the row
A permission that gains a deny rule reading object.locked narrows access by
the locked rows and by nothing else. Every caller that decides without the row
breaks anyway: can answered allowed: true for a bookseller who passed no
listing, and after the edit it answers unevaluable and names object.locked
in missing.
import { , , } from '@evanion/acl';
import type { Matrix } from '@evanion/acl';
type = { : string; : string[] };
type = { : { ?: boolean } };
const = {
: 'listing.read',
: 'listing',
: 'read',
: [
{ : [{ : 'subject.roles', : 'contains', : 'bookseller' }] },
],
} as ;
const : Matrix = { : 'listings@7', : [] };
const : Matrix = {
: 'listings@8',
: [
{
...,
: [
{ : [{ : 'object.locked', : 'eq', : true }] },
],
},
],
};
const : = { : 'u1', : ['bookseller'] };
// A gateway that decides in front of the fetch passes no listing.
const = <, >().(, 'listing', 'read');
const = <, >().(, 'listing', 'read');
.; // -> true
.; // -> false
.; // -> 'unevaluable'
.; // -> ['object.locked']
const = (, );
// The narrowing, which is what the added deny branch did to the allowed set.
const = (, 'withdrawn');
[0]?.; // -> 'deny-branch-added'
// The second finding, which is what it did to every caller.
const = (, 'reads-object');
[0]?.; // -> false
[0]?.; // -> true
[0]?..; // -> ['object.locked']diffMatrix reports two findings for that edit. The withdrawn one, cause
deny-branch-added, is the narrowing and the smaller half. The reads-object
one is what every caller feels: a gateway that decides in front of the fetch
goes from passing the request to refusing it, and a screen that drew a control
now holds a decision it has to refetch the row to settle.
What diffMatrix cannot derive from two acl documents
diffMatrix reads two documents and nothing else, so four questions stay with
the reviewer:
| The report does not say | Why |
|---|---|
| how many people a grant reaches | The matrix lists no people. Count who holds bookseller in whatever system stores your users. |
| whether a grant was already in force | Finding the newly granted set means negating every existing allow rule together, which grows exponentially with the number of rules. |
| whether an added branch grants anything | A rule requiring subject.tier to equal both probation and permanent matches nobody and reads as a grant. |
| what an edit in place did | A rule edit, a two-sided change and a list of fields carrying * or ! come back undetermined. |