Skip to content

Hooks & events

Two complementary ways to plug into the engine's lifecycle:

  • Hooks are configured up front (the hooks option), 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 from beforeRequest.
  • 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

javascript
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 } });
    },
  },
});
HookSignatureWhen it runs
beforeRequest(ctx) => void | argsOnce 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 }. callId is the request's kerberosCallId (the same one audit logs, telemetry spans and events carry); args are 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 enrichment args reads the replacement and enriched is true. Assigning ctx.args throws 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 (0 of 1 for isAllowed), the resource object and the requested actions.
  • result{ actions: { [action]: 'EFFECT_ALLOW' | 'EFFECT_DENY' }, outputs, validationErrors?, meta?, reason?, errorName? } (frozen), always with canonical EFFECT_* strings (never the effectAsBoolean view). For a resource whose evaluation failed, every action is EFFECT_DENY, reason is 'evaluation-error' and errorName names the error — the same marker the decision event and the audit entry carry.
  • summary{ success, durationMs, error?, failClosed?, enriched? }. success is false whenever the request failed, including when onError: 'deny' converted the failure into a fail-closed result — failClosed: true tells the two apart; enriched: true says beforeRequest replaced 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.

javascript
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 throwsWhat happens
beforeRequest, afterRequest after a successful requestWrapped 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 replacementSame as a throw: KerberosHookError with the KerberosValidationError as cause.
beforeResource, afterResource inside a checkResources batchIsolated 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 isAllowedThe single resource is the request — follows onError.
afterResource after a failed resource, afterRequest after a failed request, onErrorSwallowed: 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 hooksTimeoutMsKerberosHookError 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 / afterResource wrap 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.duration histogram (by kerberos.hook), so a slow hook is attributable instead of looking like an engine regression.

Events

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

EventPayloadWhen
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 (the kerberosCallId of the response, the audit entries and the telemetry span), so a decision can be joined to its request:end and to the resolver's events (see below). enriched: true marks requests whose arguments a beforeRequest hook replaced.
  • Identity onlyprincipal is { id, roles } and resource is { kind, id, scope?, policyVersion? }; attribute bags are never included (they may hold secrets). Hooks get the full context; events get correlation data. Unlike telemetry's includeIdentity, events are not gated: listeners run in-process, so the consumer owns the PII policy — project the payload before shipping it anywhere.
  • No Error objects — failures are a message string (error) plus errorName, 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'} and console.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

HooksEvents
Registrationhooks constructor option, one function per namekerberos.on(name, fn), any number of listeners
TimingAwaited inside the requestSynchronous, after the fact
Can change the outcomeYes — veto by throwing (KerberosHookError, follows onError) or enrich by returning replacement arguments from beforeRequestNever — failures are contained
DataFull validated arguments and results (frozen views)Identity-only payloads
Fires for malformed argumentsNorequest:start / request:error / request:end
Bounded byhooksTimeoutMsnothing — keep listeners synchronous
Typical useGuards, enrichment, blocking auditMetrics, alerting, dashboards

Released under the MIT License.