Skip to Content
AuthorizationWhat can throw?

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 can throw?

Every error the @evanion/acl entry point throws extends AclConfigError except ActionNotAllowedError, which pickAllowedFields throws when the action it was handed was refused.

Catching every acl configuration fault at once

Construction is the call that builds an evaluator: policy().build(), hydratePolicy or parseMatrix. A service constructs at startup, and a malformed document should fail the deploy with a message naming the value to change. AclConfigError is the base of every class construction raises, so one catch around construction catches whichever one it throws. The fixtures on this page are cast as never, because each one is malformed on purpose and would not type-check as a document:

import { , } from '@evanion/acl'; const = { : 'v1' } as never; let : Error | undefined; try { (); } catch () { = as Error; } // Every class the package raises at construction extends this one, so a // startup guard catches the set without naming each member. instanceof ; // -> true instanceof ; // -> true

How to list every validation problem

By default, the construction process is designed to fail fast; it throws the first fault it encounters. For an editor saving drafts, this means the user only sees one problem per save.

To provide a comprehensive health check of a document, you can use validateAll. Instead of throwing, this function returns a complete list of all issues as plain objects. Each object contains:

  • code: The name of the error class.
  • message: The error text the throwing path raises.
  • path: A JSON pointer to the exact location of the error.
  • permissionKey: Included specifically when the fault occurs within a permission.

If the returned list is empty, construction is successful. If issues exist, the first item in the list will share the same code as the error that hydratePolicy would have thrown for that document.

import { } from '@evanion/acl'; // A draft from a policy editor with two faults, in two permissions. const = { : [ { : 'question.read', : 'question', : 'read', : 'everyone', }, { : 'question.update', : 'question', : 'update', : [{ : [{ : 'subject.id', : 'equals', : 'a' }] }], }, ], } as never; // validateAll never throws, and it reports both faults in one call. const = (); .; // -> 2 [0]?.; // -> 'InvalidPermissionError' [0]?.; // -> '/permissions/0/visibility' [0]?.; // -> 'question.read' [1]?.; // -> 'InvalidConditionError' [1]?.; // -> '/permissions/1/rules/0/when/0' // A valid document returns an empty list. ({ : [] }); // -> []

Including freshness and cloning faults

To ensure your validation includes freshness faults, pass the same fetchedAt and maxStale arguments that you provide to hydratePolicy as the second argument to validateAll.

Note that some failures happen before validation can even begin. If a document fails to clone (for example, if it contains a function), that error is reported first, mirroring the behavior of the standard construction process.

The one acl error outside AclConfigError

pickAllowedFields keeps only the fields of a form post the subject may write, from a decision canFields returned. It throws ActionNotAllowedError at request time, on a document that is entirely valid, when the subject may not take the action at all:

import { , } from '@evanion/acl'; import { , } from '@evanion/acl'; type = { : string; : string }; const = <{ : string }, { : }>() .('question', () => .('update', .('object.askedBy', 'subject.id')), ) .(); // Somebody else's question, so the action itself is refused. const = { : 'customer-92', : 'In stock?' }; const = { : 'Is Wingspan in stock?' }; const = .( { : 'customer-41' }, 'question', 'update', , 'write', , ); ..; // -> false let : Error | undefined; try { (, ); } catch () { = as Error; } instanceof ; // -> true instanceof ; // -> false

ActionNotAllowedError extends Error directly, so a startup guard around construction does not catch it, and the handler that writes has to. When the acl action itself is refused covers fd.allowed and fd.action.allowed, the two booleans behind it.

The assertions in @evanion/acl/testing throw AclAssertionError, which How do I test a contract I consume? covers.

When an acl query throws, and when it refuses

A refusal is a decision carrying allowed: false, and a throw ends the call. Where the document came from decides which one a query meets:

  • hydratePolicy is the open path, for a document this service wrote. A key the document never held is an authoring mistake, so a query naming one throws.
  • parseMatrix is the closed path, for a document another service published. The owner’s next release can remove a key this consumer still asks about, so a query naming one answers unknown-action.
import { , } from '@evanion/acl'; function (: () => unknown): string | undefined { try { (); } catch () { return ( as Error).; } return ; } const = { : 'v1', : [ { : 'question.read', : 'question', : 'read', : [{ : [] }], }, ], } as never; const = (); const = (); // The document holds `question.read` and nothing else. A query naming anything // else is typed `never` here, because a caller writing one has already left // what the document declares. const = {} as never; const = 'question' as never; const = 'read' as never; const = 'delete' as never; const = 'coupon' as never; (() => .(, , )); // -> 'UnknownPermissionError' (() => .(, , )); // -> 'UnknownObjectKeyError' const = .(, , ); .; // -> false .; // -> 'unknown-action'

The open path throws UnknownPermissionError for an action the document never declared, and UnknownObjectKeyError for an object kind it never configured. Everything else on this page throws at construction: at startup for your own document, and at adoption for a foreign one. A consumer that adopts a fetched document while serving a request meets the throw there, and keeping the last document that constructed answers it.

What refuses an acl document

The fixtures below are matrix JSON. Each permission has key, object, action and a rules list, and each rule holds a when list of conditions; The matrix: your rules as JSON has the full form. Each case names its input first, and raised reports the class, so one case reads as one line:

import { } from '@evanion/acl'; /** The error a call raises, by name, or `undefined` when it raises none. */ function (: () => unknown): string | undefined { try { (); } catch () { return ( as Error).; } return ; } const = { : 'question.read', : 'question', : 'read', : [{ : [] }], }; const = { : 'v1' } as never; const = { : {}, : [] } as never; const = { : 'v1', : [{ ..., : 'question.update' }], } as never; const = { : 'v1', : [, ] } as never; const = { : 'v1', : [{ : 'question.read', : 'question' }], } as never; const = { : 'v1', : [{ ..., : [{ : 'x' }] }], } as never; (() => ()); // -> 'InvalidMatrixError' (() => ()); // -> 'InvalidMatrixError' (() => ()); // -> 'KeyMismatchError' (() => ()); // -> 'DuplicatePermissionError' (() => ()); // -> 'InvalidPermissionError' (() => ()); // -> 'InvalidRuleError' // Three conditions, one class. The message tells them apart. const = (: unknown) => ({ : 'v1', : [{ ..., : [{ : [] }] }], }) as never; const = ({ : 'subject.id', : 'nope', : 1 }); const = ({ : 'subject.a.b', : 'eq', : 1 }); const = ({ : 'user.id', : 'eq', : 1 }); (() => ()); // -> 'InvalidConditionError' (() => ()); // -> 'InvalidConditionError' (() => ()); // -> 'InvalidConditionError'
ClassThe input that raises it
InvalidMatrixErrorno permissions array, or a version that is not a string or a number
KeyMismatchErrora key that is not exactly `${object}.${action}`
DuplicatePermissionErrortwo permissions sharing one key
InvalidPermissionErrora permission missing its action, or a malformed object, rules or fields, including a field path that cannot name a field or a list that states two intents for one path
InvalidRuleErrora rule whose when is absent or not an array
InvalidConditionErroran unknown op, a path nested below its scope, or an unknown scope

InvalidConditionError carries key, field and where, and where is the path to the condition inside the permission, so a document with forty rules names the one to fix:

import { } from '@evanion/acl'; /** What an `InvalidConditionError` carries beside its message. */ type = Error & { ?: string; ?: string; ?: string; }; const = { : 'v1', : [ { : 'question.read', : 'question', : 'read', : [{ : [{ : 'subject.a.b', : 'eq', : 1 }] }], }, ], } as never; let : | undefined; try { (); } catch () { = as ; } ?.; // -> 'InvalidConditionError' ?.; // -> 'question.read' ?.; // -> 'subject.a.b' ?.; // -> 'rules[0].when[0]'

What refuses an acl schema or field rule

A schema is checked for its own shape first, and then every condition naming a declared kind is checked against it. Field rules are checked whether a schema is present or not.

import { } from '@evanion/acl'; function (: () => unknown): string | undefined { try { (); } catch () { return ( as Error).; } return ; } const = { : 'question.read', : 'question', : 'read', : [{ : [] }], }; const = { : { : { : { : 'string' } } } }; const = { : 'v1', : { : { : { : { : 'nope' } } } }, : [], } as never; const = { : 'v1', , : [ { ..., : [{ : [{ : 'object.pinned', : 'eq', : true }] }], }, ], } as never; const = { : 'v1', , : [ { ..., : [ { : [{ : 'object.status', : 'contains', : 'open' }] }, ], }, ], } as never; (() => ()); // -> 'InvalidSchemaError' (() => ()); // -> 'UnknownFieldError' (() => ()); // -> 'FieldTypeMismatchError' const = (: unknown) => ({ : 'v1', : [{ ..., : }] }) as never; const = ({ : ['*', '!address', 'address.city'] }); const = ({ : ['!status'] }); const = ({ : { : ['open'], : { : [] } }, }); (() => ()); // -> 'InvalidPermissionError' (() => ()); // -> 'DenyWithoutBaselineError' (() => ()); // -> 'TargetsTransitionsConflictError'
ClassThe input that raises it
InvalidSchemaErrora malformed schema, or a field type outside string, number, boolean and instant
UnknownFieldErrora condition naming a field the schema does not declare
FieldTypeMismatchErroran operator that does not fit the type, such as contains on a string
DenyWithoutBaselineErrora !name with no * or listed ancestor
TargetsTransitionsConflictErrorone field given both targets and transitions

Field paths

To define which data a user can access, you specify a list of field paths in @evanion/acl. This list uses a specific syntax where * (wildcard) and !name (exclusion) denote the intent of the rule.

Because these symbols carry special meaning in the list, a whole segment cannot be * and a segment cannot start with !; a name like a*b or x! is still a plain name. If a path is malformed, the package throws an InvalidPermissionError. This error occurs if a path contains:

  • An empty segment.
  • A __proto__ segment.
  • A * segment.
  • A segment starting with !.
  • More than 32 segments.

The system also prevents conflicting intents for the same path. An InvalidPermissionError is raised if the list contains:

  • An entry and its own exclusion side-by-side.
  • An allow rule positioned below an excluded ancestor.
  • A targets or transitions key with another rule below it.

When these conflicts occur, the error identifies the specific permission key and both conflicting entries. For a full guide on this syntax, see Which fields may they write?.

Users employing typed authoring may encounter two additional errors while naming rules: DuplicateRuleIdError and AmbiguousRuleIdError. These are detailed in the Typed authoring tables.

What refuses an acl consumer’s freshness settings

A consumer adopting another service’s document may report fetchedAt, the instant it last fetched its copy. The document’s maxStale then bounds how long that copy stays good, the freshness budget. The check runs where the evaluator is built, so a misconfigured consumer fails at startup:

import { } from '@evanion/acl'; function (: () => unknown): string | undefined { try { (); } catch () { return ( as Error).; } return ; } const = { : 'question.read', : 'question', : 'read', : [{ : [] }], }; const = { : 'v1', : [] } as never; const = { : 'v1', : 1000, : [] } as never; const = { : 0 }; const = { : 'nope' } as never; (() => (, )); // -> 'MissingFreshnessBudgetError' (() => (, )); // -> 'InvalidFreshnessError'

MissingFreshnessBudgetError means the consumer reported fetchedAt against a document that states no maxStale, and the owner has to state how long a check lasts. InvalidFreshnessError means the consumer’s fetchedAt does not parse as an instant, or its own maxStale is not a finite, non-negative number of milliseconds. How long a consumer may keep deciding on an acl contract has the budget.

What refuses an acl composition

federatedPolicies joins the documents of several services, each an origin, behind one evaluator. A deny overlay is a set of deny rules another team merges into your document with applyDenyOverlay, limited to the keys you list as vetoable. Both are checked when they are built, and each refusal names the key it refused:

import { , } from '@evanion/acl'; import { , , } from '@evanion/acl'; function (: () => unknown): string | undefined { try { (); } catch () { return ( as Error).; } return ; } const = { : 'question.read', : 'question', : 'read', : [{ : [] }], }; const = { : 'v1', : [] } as never; const = { : 'v1', : { : { : { : { : 'boolean' } } } }, : [], } as never; const = { : (), : () } as never; const = { 'question.read': [{ : [] }] }; const = { : 'object.locked', : 'eq' as , : true }; const = { 'question.read': [{ : [] }] }; const = { : [] }; const = { : ['question.read'] }; (() => ()); // -> 'OriginCollisionError' (() => (, , )); // -> 'UnvetoablePermissionError' (() => (, , )); // -> 'MissingVetoSchemaError' (() => (, , )); // -> undefined const = <{ : string }, { : { : string } }>() .('question', () => .('read', .)) .(); const = { : ['question.read'] } as never; (() => (, 'reduced', )); // -> 'UnpublishedVetoableError'
ClassThe input that raises it
OriginCollisionErrortwo origins in federatedPolicies claiming one key
UnvetoablePermissionErrora deny overlay reaching a key the owner did not list as vetoable
MissingVetoSchemaErrora vetoable key whose object kind the schema does not declare
UnpublishedVetoableErrora vetoable key that serialize(access, 'reduced') would drop

Many services has the topology OriginCollisionError comes from, and the cross-cutting deny is where the three veto errors belong.

Last updated on