Hooks & events
Two complementary ways to plug into the engine's lifecycle:
- Hooks are configured up front (the
hooksoption), run inside the request flow and are awaited — use them to run your own logic around a decision (tenant guards, rate limits, a blocking audit sink that must complete before the response), to veto a request by throwing, or to enrich it by returning replacement arguments frombeforeRequest. - Events are subscribed from outside (
kerberos.on(name, listener)), fire synchronously after the fact and can never affect a decision — use them to feed metrics, alerting or dashboards without parsing audit logs.
Both live on Kerberos and on the built-in RelationResolver.
Hooks
const kerberos = new Kerberos(policies, derivedRoles, {
onError: 'deny',
hooksTimeoutMs: 500, // optional: a hanging hook fails instead of hanging authorization
hooks: {
// Once per request, after the arguments validated. Throw to veto, return
// a replacement to enrich.
async beforeRequest(ctx) {
if (ctx.args.principal.attr?.suspended) throw new Error('suspended principal');
const groups = await directory.groupsOf(ctx.args.principal.id);
return { ...ctx.args, principal: { ...ctx.args.principal, roles: [...ctx.args.principal.roles, ...groups] } };
},
// Once per request — on success, on failure and on the fail-closed path.
async afterRequest(ctx, summary) {
await audit.write({ callId: ctx.callId, kind: ctx.reqKind, ...summary });
},
// Around each resource evaluation (isAllowed, checkResources).
beforeResource(ctx, info) {
metrics.increment('authz.resource', { kind: info.resource.kind });
},
afterResource(ctx, info, result) {
if (result.actions.delete === 'EFFECT_ALLOW') alerts.notify(ctx, info);
},
// When a request fails — receives the error before afterRequest.
onError(error, ctx) {
sentry.captureException(error, { extra: { callId: ctx.callId } });
},
},
});| Hook | Signature | When it runs |
|---|---|---|
beforeRequest | (ctx) => void | args | Once per isAllowed / checkResources / planResources call, after argument validation and before any evaluation. A returned object replaces the arguments. |
beforeResource | (ctx, info) | Before each resource evaluation — once for isAllowed, once per entry of a checkResources batch (never for planResources). |
afterResource | (ctx, info, result) | After each resource evaluation — successful, or failed (then result.reason is set and a throw is swallowed). |
afterRequest | (ctx, summary) | Once per request — on success, on failure, and on the onError: 'deny' fail-closed path. |
onError | (error, ctx) | When the request fails, before afterRequest. |
Hooks may be sync or async; they are awaited. A hook changes the outcome in exactly two ways: by throwing (a veto) or — beforeRequest only — by returning a replacement for the arguments. Every other return value is ignored (an expression-bodied arrow such as () => metrics.increment() is safe).
What a hook receives
ctx— one frozen object shared by every hook of the request:{ reqKind: 'IsAllowed' | 'CheckResources' | 'PlanResources', callId, reqId?, args, enriched }.callIdis the request'skerberosCallId(the same one audit logs, telemetry spans and events carry);argsare the validated arguments of the method — the same principal/resource objects the engine evaluates (under Zod, the parsed output; without a validation backend, the caller's own objects). After an enrichmentargsreads the replacement andenrichedistrue. Assigningctx.argsthrows in strict mode: mutating the arguments in place is not the contract — it leaks into the caller's objects without a validation backend and leaves no trace in the audit log. Enrich by returning a replacement instead.info—{ index, total, resource, actions }(frozen): the resource's position in the request (0of1forisAllowed), the resource object and the requested actions.result—{ actions: { [action]: 'EFFECT_ALLOW' | 'EFFECT_DENY' }, outputs, validationErrors?, meta?, reason?, errorName? }(frozen), always with canonicalEFFECT_*strings (never theeffectAsBooleanview). For a resource whose evaluation failed, every action isEFFECT_DENY,reasonis'evaluation-error'anderrorNamenames the error — the same marker thedecisionevent and the audit entry carry.summary—{ success, durationMs, error?, failClosed?, enriched? }.successisfalsewhenever the request failed, including whenonError: 'deny'converted the failure into a fail-closed result —failClosed: truetells the two apart;enriched: truesaysbeforeRequestreplaced the arguments.
Enriching a request
beforeRequest may return a replacement arguments object (sync or async). The engine re-validates it with the method's own validator (plus the method's invariants — planResources' action rules, and a checkResources replacement must keep the batch's resource count, since results are positional), then evaluates the replacement instead of the original. Everything downstream sees the enriched request: the decision, the per-resource hooks, the audit entry (enriched: true, principalRoles are the enriched roles), the span (kerberos.request.enriched), the decision / plan / request:end events (enriched: true) and afterRequest's summary.
hooks: {
async beforeRequest(ctx) {
const groups = await directory.groupsOf(ctx.args.principal.id);
return { ...ctx.args, principal: { ...ctx.args.principal, roles: [...ctx.args.principal.roles, ...groups] } };
},
}A replacement that fails validation (or breaks the batch shape) is the hook's failure: it surfaces as KerberosHookError (hook: 'beforeRequest', cause is the KerberosValidationError) and follows onError like a veto. The caller's original objects are never modified.
Execution order
request:start (event)
validate arguments ← KerberosValidationError: no hook fires
beforeRequest(ctx) ← throw = veto · return object = enrich
beforeResource(ctx, info[0]) → evaluate → afterResource(ctx, info[0], result)
beforeResource(ctx, info[1]) → evaluate → afterResource(ctx, info[1], result)
decision (event, one per resource)
afterRequest(ctx, { success: true })
request:end (event)A checkResources batch evaluates its resources concurrently (bounded by maxConcurrency), so the per-resource hooks of different resources interleave; each resource's beforeResource/afterResource pair is ordered, and info.index is the resource's position, not the invocation order. Per-resource hooks run inside the resource's maxConcurrency slot, so a slow afterResource is back-pressure on the batch.
The failure path:
beforeRequest(ctx)
beforeResource(ctx, info) → evaluate ✖ → afterResource(ctx, info, { reason: 'evaluation-error', … }) ← swallowed
request:error (event)
onError(error, ctx) ← always swallowed
afterRequest(ctx, { success: false, error }) ← always swallowed
request:end (event, success: false)
→ onError: 'throw' rethrows; onError: 'deny' returns the fail-closed result (summary.failClosed = true)Inside a checkResources batch a failing resource does not fail the request: its afterResource sees the fail-closed result, the batch resolves, and afterRequest sees success: true.
Error contract
| Hook that throws | What happens |
|---|---|
beforeRequest, afterRequest after a successful request | Wrapped in KerberosHookError (hook names it, cause is your error) and handled like any evaluation error: onError: 'throw' propagates it, 'deny' returns the fail-closed result. |
beforeRequest returning an invalid replacement | Same as a throw: KerberosHookError with the KerberosValidationError as cause. |
beforeResource, afterResource inside a checkResources batch | Isolated to that resource, exactly like an evaluation error: all its actions come back EFFECT_DENY with reason: 'evaluation-error', errorName: 'KerberosHookError' under includeMeta; the other resources are unaffected and the batch resolves (so afterRequest sees success: true). |
beforeResource, afterResource in isAllowed | The single resource is the request — follows onError. |
afterResource after a failed resource, afterRequest after a failed request, onError | Swallowed: the original error is what surfaces. The failure is counted on kerberos.observability.failures{kerberos.observability.sink: 'hooks'} and console.warned once per instance. |
Any hook exceeding hooksTimeoutMs | KerberosHookError with timedOut: true, then the row above that applies to that hook. |
Hooks never run for malformed arguments (KerberosValidationError), and the pairing invariant always holds: once beforeRequest ran, onError (on failure) and afterRequest run exactly once — even when the success-path afterRequest was itself the failure, it is not re-invoked. Unknown hook names and non-function values are rejected at construction with a TypeError. The message of a KerberosHookError embeds your hook's error message (and reaches the request:error event as a string) — keep secrets out of it.
Timeouts
Hooks run inside the request, so a hook that never settles hangs every authorization call behind it. hooksTimeoutMs (off by default, like relationsTimeoutMs) bounds every hook invocation: on expiry the hook fails as KerberosHookError { hook, timedOut: true } and then follows the error contract of that hook — a timed-out beforeRequest vetoes (following onError), a timed-out afterResource in a batch fails that resource, a timed-out onError is swallowed. The abandoned hook keeps running in the background; the timeout only unblocks the request. Synchronous hooks are never raced.
Performance notes
- Request-level hooks add one awaited call per request; the fully-synchronous evaluation driver (no cache, no relations) stays in use.
beforeResource/afterResourcewrap each evaluation in an async frame, so they are the only hooks with a per-resource cost.- With no hooks configured the runner is a no-op object and every call site short-circuits on one boolean.
- With telemetry enabled every hook invocation is recorded on the
kerberos.hooks.durationhistogram (bykerberos.hook), so a slow hook is attributable instead of looking like an engine regression.
Events
kerberos
.on('decision', ({ callId, principal, resource, actions }) => {
for (const [action, effect] of Object.entries(actions)) {
metrics.increment('authz.decisions', { kind: resource.kind, action, effect });
}
})
.on('request:end', ({ reqKind, durationMs, success }) => {
metrics.timing('authz.request', durationMs, { reqKind, success });
})
.on('cache:error', ({ key, errorName }) => alerts.notify(`policy cache read failed: ${key} (${errorName})`));on / once / off / removeAllListeners are chainable; listenerCount(name) reports subscriptions. Unknown event names are a type error in TypeScript and a TypeError at runtime (a typo cannot register a listener that never fires). There is deliberately no public emit (events are the engine's outbound signal — a consumer must not be able to forge decision entries into an audit pipeline) and no bare 'error' event.
| Event | Payload | When |
|---|---|---|
request:start | { callId, reqKind, reqId? } | Before argument validation, once per public call. |
request:end | { callId, reqKind, reqId?, durationMs, success, error?, errorName?, enriched? } | Always, last. success: false on failure — also on the onError: 'deny' path. |
request:error | { callId, reqKind, reqId?, error, errorName } | When the request failed (validation errors included). |
decision | { callId, reqKind, reqId?, index, principal, resource, actions, reason?, errorName?, enriched? } | One per evaluated resource of isAllowed / checkResources; fail-closed decisions carry reason: 'evaluation-error'. |
plan | { callId, reqKind, reqId?, principal, resource, actions, filterKind, opaqueCount, relationCount, enriched? } | After planResources built its filter. |
relations:resolved | { callId, principal, resource, relations, granted, mode: 'list' | 'check', durationMs } | After the relations resolver answered for one resource. |
cache:hit / cache:miss / cache:error | { key, error?, errorName? } | Per policy-cache read. No callId: the lookup path has no request context (parity with the Cache.* log entries). |
Rules every payload follows:
- Correlation — every request-scoped payload carries the request's
callId(thekerberosCallIdof the response, the audit entries and the telemetry span), so adecisioncan be joined to itsrequest:endand to the resolver's events (see below).enriched: truemarks requests whose arguments abeforeRequesthook replaced. - Identity only —
principalis{ id, roles }andresourceis{ kind, id, scope?, policyVersion? }; attribute bags are never included (they may hold secrets). Hooks get the full context; events get correlation data. Unlike telemetry'sincludeIdentity, events are not gated: listeners run in-process, so the consumer owns the PII policy — project the payload before shipping it anywhere. - No
Errorobjects — failures are a message string (error) pluserrorName, safe to ship to metrics or alerting as-is. - Fresh objects — each payload is built for that emission (only when someone listens); mutating it affects nothing.
- Contained listeners — emission is synchronous and fire-and-forget. A listener that throws, or returns a rejecting promise, never affects the decision: the failure is counted on
kerberos.observability.failures{kerberos.observability.sink: 'events'}andconsole.warned once per instance (asynchronously for a rejecting listener — after the response returned). - Cheap and synchronous listeners — synchronous listeners cost no promise (the emitter only allocates when a listener returns a thenable). An async listener is awaited by nobody: under load its pending work accumulates with no back-pressure, so do the I/O through your own bounded queue, not in the listener.
Listener leaks
Subscribing is a startup concern. on() inside request handling is the classic leak: the listener list grows without bound and every emission runs all of it. The first subscription past maxListeners (default 10, per event name) logs one console.warn — never a limit, only a signal; raise maxListeners if you really need more, or set it to 0 to disable the warning.
The emitter is a small built-in class, the same on Node.js and in the browser — Kerberos is not a node:events EventEmitter (instanceof EventEmitter, events.once(kerberos, …) or addListener do not apply; wrap on/off if you need them).
Resolver events
The built-in RelationResolver emits request:start / request:end / request:error ({ callId, kind: 'check' | 'list' | 'lookupSubjects' | 'lookupResources', … }), relation:checked ({ callId, kind, resource: { kind, id }, relation, subject, allowed, enriched? } — one per relation or permission checked) and cache:hit / cache:miss / cache:error for tuple-document reads ({ key, kind: 'relation', callId, error?, errorName? }). When the engine calls the resolver through the relations seam, callId is the engine's kerberosCallId, so resolver and engine events line up; standalone calls get a generated id.
Hooks vs events
| Hooks | Events | |
|---|---|---|
| Registration | hooks constructor option, one function per name | kerberos.on(name, fn), any number of listeners |
| Timing | Awaited inside the request | Synchronous, after the fact |
| Can change the outcome | Yes — veto by throwing (KerberosHookError, follows onError) or enrich by returning replacement arguments from beforeRequest | Never — failures are contained |
| Data | Full validated arguments and results (frozen views) | Identity-only payloads |
| Fires for malformed arguments | No | request:start / request:error / request:end |
| Bounded by | hooksTimeoutMs | nothing — keep listeners synchronous |
| Typical use | Guards, enrichment, blocking audit | Metrics, alerting, dashboards |