Skip to Content
AuthorizationWhat can throw?

What can throw?

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

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

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
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 = ({ : ['body', '!status'] }); const = ({ : ['!status'] }); const = ({ : { : ['open'], : { : [] } }, }); (() => ()); // -> 'BangInAllowListError' (() => ()); // -> '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
BangInAllowListErrora !name inside a list of names
DenyWithoutBaselineErrora !name with no * beside it
TargetsTransitionsConflictErrorone field given both targets and transitions

* and !name are syntax in a list of fields, so the last three are raised by the shape of the list. Which fields may they write? has that syntax. A typed author meets two more while naming rules, DuplicateRuleIdError and AmbiguousRuleIdError, which 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