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 ; // -> trueThe 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 ; // -> falseActionNotAllowedError 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:
hydratePolicyis 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.parseMatrixis 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 answersunknown-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'| Class | The input that raises it |
|---|---|
InvalidMatrixError | no permissions array, or a version that is not a string or a number |
KeyMismatchError | a key that is not exactly `${object}.${action}` |
DuplicatePermissionError | two permissions sharing one key |
InvalidPermissionError | a permission missing its action, or a malformed object, rules or fields |
InvalidRuleError | a rule whose when is absent or not an array |
InvalidConditionError | an 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'| Class | The input that raises it |
|---|---|
InvalidSchemaError | a malformed schema, or a field type outside string, number, boolean and instant |
UnknownFieldError | a condition naming a field the schema does not declare |
FieldTypeMismatchError | an operator that does not fit the type, such as contains on a string |
BangInAllowListError | a !name inside a list of names |
DenyWithoutBaselineError | a !name with no * beside it |
TargetsTransitionsConflictError | one 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'| Class | The input that raises it |
|---|---|
OriginCollisionError | two origins in federatedPolicies claiming one key |
UnvetoablePermissionError | a deny overlay reaching a key the owner did not list as vetoable |
MissingVetoSchemaError | a vetoable key whose object kind the schema does not declare |
UnpublishedVetoableError | a 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.