Skip to Content
AuthorizationHow do I use rules another service published?

How do I use rules another service published?

@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.

parseMatrix(json) builds an evaluator over a matrix another service published, and answers unknown-action for any permission that document does not carry.

Adopting another service’s acl matrix with parseMatrix

Baize’s storefront draws buttons from rules another Baize service owns. A storefront that writes those rules a second time is right until the owner changes one: the owner keeps refusing correctly, no alarm fires, and the button stays hidden against a server that would have allowed the call.

The matrix is the JSON document a policy flattens to, which the Overview introduces. The owner serves it, and the storefront fetches it and hands it to parseMatrix:

import { } from '@evanion/acl'; const = ({ : [ { : 'question.read', : 'question', : 'read', : [ { : [ { : 'subject.roles', : 'contains', : 'bookseller' }, ], }, ], }, ], }); .({ : 's1' }, 'question', 'delete').; // -> 'unknown-action'

Each permission carries key, object, action and a rules list of allow rules, and each rule holds a when list of conditions that must all hold. This document grants question.read to a subject whose roles array holds bookseller. It carries no question.delete, so asking for delete answers unknown-action with allowed: false. A real document holds every key its owner publishes, across every object kind it owns, and the whole list builds one evaluator.

Why parseMatrix answers where hydratePolicy throws

parseMatrix(json, options?) is hydratePolicy(json, { ...options, closed: true }). closed: true is the flag that turns a missing key into a refusal, and it sits after the spread, so no option a caller passes turns it off. The two calls differ only on a key the document does not carry:

  • hydratePolicy throws UnknownObjectKeyError for an object kind the document lacks, and UnknownPermissionError for an action it lacks.
  • parseMatrix answers unknown-action with allowed: false for both.

A malformed document throws at construction on either path.

Which path a document gets depends on where it came from. A key your own policy lacks is a programmer error, and the throw points at the line. A browser rebuilding your own access.matrix with hydratePolicy is on that path too, because your own code wrote both the document and the question. A key a foreign document lacks means its owner removed the permission, or this consumer was written against an older revision, and a throw there takes a page down over a permission nobody holds.

Closed mode on the consumer's side. parseMatrix answers from the adopted rules for every key the document carries, and answers unknown-action with allowed false for every key it does not, so a permission the producer removed does not take the page down. The owning service decides again on its own matrix when the call arrives.

Neither path grants a key it does not hold. The owning service decides again on its own matrix when the call reaches it.

What parseMatrix refuses before an acl evaluator exists

parseMatrix holds a foreign document to the same checks a local one passes, and each refusal extends AclConfigError. The common ones:

Refused at constructionWhen
InvalidMatrixErrornot an envelope, no permissions array, a bad version
InvalidPermissionErrora malformed object, action, rules or fields
InvalidRuleErrora rule whose when is absent or not an array
InvalidConditionErroran unusable shape, path, depth or operator
KeyMismatchErrorkey is not exactly `${object}.${action}`
DuplicatePermissionErrortwo permissions share a key
UnknownFieldErrora condition names a field the document’s schema omits
FieldTypeMismatchErroran operator that does not fit the schema’s declared type

What can throw? tables the rest. A document fetched over the network can fail for reasons your process cannot fix, so keep the last document that constructed. The orders: prefix on the object kind names the service that owns it:

import { , } from '@evanion/acl'; import type { Matrix } from '@evanion/acl'; const = { : 'orders:order.refund', : 'orders:order', : 'refund', } as ; const = { : 'subject.roles', : 'contains', : 'bookseller', } as ; // The last document `orders` served that constructed. let = ({ : 'orders@7', : [{ ..., : [{ : [] }] }], }); /** Adopt a fetched document, or keep the one in hand and name the refusal. */ function (: Matrix): string | undefined { try { = (); return ; } catch () { if ( instanceof ) return .; throw ; } } // `orders@8` lost its refund rule's conditions on the way. const = { : 'orders@8', : [{ ..., : [{ : null }] }], } as never; (); // -> 'InvalidRuleError' .; // -> 'orders@7' const = { : 'u1', : ['bookseller'] }; .(, 'orders:order', 'refund').; // -> true

orders@8 lost its rule’s conditions in transit, and InvalidRuleError refuses it, so a damaged document never becomes a permission that grants everyone. A rule with an empty when grants everyone, and a permission with an empty rules list grants nobody. The storefront keeps answering from orders@7, and readopt returns the class name for an alarm.

A document may carry a schema, which lists the fields of each object kind under schema.objects. It binds per object kind: a permission on a kind the schema does not list is unchecked, and a document with no schema checks no path. What the acl schema refuses works one through.

Which revision of an acl document is answering

version is a string or a number at the top of the document, and it survives the JSON round trip, so a consumer holding a document for weeks can say which revision decided. A process that changes a document before building an evaluator over it runs a revision the owner never shipped. A deny overlay does that: it merges another team’s deny rules into the document, as Many services covers. hydratePolicy(matrix, { version }) names the revision this process runs, and parseMatrix takes the same option:

import { } from '@evanion/acl'; const = ({ : 'orders@7', : [ { : 'orders:order.refund', : 'orders:order', : 'refund', : [], }, ], }).; // This process merged deny overlay 41 into `orders@7` before construction, so // it runs a revision `orders` never shipped. The option names that revision, // and the frozen `matrix` carries it. const = (, { : 'orders@7+veto@41' }); .; // -> 'orders@7+veto@41' ..; // -> 'orders@7+veto@41'

The option wins over the document’s own version, and access.matrix, which the evaluator freezes, carries the winner. So the version that decided is the version that crosses a server-side rendering (SSR) boundary. A document stating no version leaves access.version undefined, and a comparison against it decides nothing. Refetching an acl matrix when its version moves sets out the comparison.

What adopting an acl document does not verify

The library neither signs a document nor verifies a signature. A matrix arriving over an unauthenticated channel is a matrix an attacker wrote, and every check above reads the shape and says nothing about where the bytes came from. Integrity belongs to the transport, the same way resolving a subject belongs to the session layer.

Last updated on