Skip to Content
AuthorizationWhat did editing my rules change about access?

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:

kindWhat the finding sayscause, one of
grantedthe second document grants access the first did notpermission-added, allow-branch-added, deny-branch-removed, field-list-widened
withdrawnthe first document granted access the second does notpermission-removed, allow-branch-removed, deny-branch-added, field-list-narrowed
undetermineddiffMatrix declines to call the change either directionrule-edited, both-sides-changed, fields-changed
reads-objectthe object.* paths this permission reads changednone; 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.closed describes a holder that adopted the document with parseMatrix: listing.reprice answers unknown-action and refuses.
  • holders.open describes a holder that built it with hydratePolicy: can throws UnknownPermissionError for listing.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 sayWhy
how many people a grant reachesThe matrix lists no people. Count who holds bookseller in whatever system stores your users.
whether a grant was already in forceFinding the newly granted set means negating every existing allow rule together, which grows exponentially with the number of rules.
whether an added branch grants anythingA rule requiring subject.tier to equal both probation and permanent matches nobody and reads as a grant.
what an edit in place didA rule edit, a two-sided change and a list of fields carrying * or ! come back undetermined.
Last updated on