hooks
beforeEvaluate, afterEvaluate, onDeny, onError, onPolicyError, onMetrics - observe and modify the evaluation lifecycle.
Hook lifecycle
Hooks let you intercept and observe the evaluation lifecycle. All hooks are optional and can be synchronous or async.
import type { IamEngineTypes } from '@gentleduck/iam'
const hooks: IamEngineTypes.IHooks = {
beforeEvaluate?(request) { /* return modified request */ },
afterEvaluate?(request, decision) { /* development-mode only */ },
onDeny?(request, decision) { /* development-mode only */ },
onError?(error, request) { /* both modes */ },
onPolicyError?(error, policyId) { /* both modes - fail-skip per-policy */ },
onMetrics?(event) { /* both modes - primitive payload */ },
}
All six hooks live under IamEngineTypes.IHooks. afterEvaluate and onDeny only fire in development mode (they receive the full AccessControl.IDecision); onMetrics and onError fire in both modes.
beforeEvaluate
Runs before the evaluation. Receives the request and must return a (possibly modified) request. Use it to enrich the request with computed context.
hooks: {
beforeEvaluate: (request) => {
return {
...request,
environment: {
...request.environment,
timestamp: Date.now(),
dayOfWeek: new Date().getDay(),
hour: new Date().getHours(),
},
}
},
}
Common use cases:
- Server-side timestamps - clients can't be trusted with time
- Computed environment - derive
dayOfWeek,hour,isWeekendfromDate.now() - Geo-IP - look up
country/regionfromip - Feature flags - pull from a flag service before evaluation
- Tenant resolution - translate
subdomain->tenantId
Keep hook work cheap. beforeEvaluate runs on every check - slow lookups should be cached or moved to subject resolution.
afterEvaluate
Runs after every evaluation. Use it for logging and auditing:
hooks: {
afterEvaluate: async (request, decision) => {
await db.insert(accessLog).values({
subjectId: request.subject.id,
action: request.action,
resource: request.resource.type,
resourceId: request.resource.id,
allowed: decision.allowed,
reason: decision.reason,
duration: decision.duration,
timestamp: new Date(decision.timestamp),
})
},
}
Fires for both allow and deny outcomes. For deny-specific logic, use onDeny instead.
onDeny
Runs only when a request is denied. Use it for alerting and security monitoring:
hooks: {
onDeny: async (request, decision) => {
metrics.increment('access.denied', {
action: request.action,
resource: request.resource.type,
})
// Alert on repeated denials from the same subject
const recentDenials = await getRecentDenials(request.subject.id)
if (recentDenials > 10) {
await alertSecurityTeam(request.subject.id)
}
},
}
onDeny runs after afterEvaluate. The same decision object is passed to both.
onError
Runs when the evaluation throws an error. Use it for error recovery and reporting. When an error occurs, the engine automatically returns a deny decision with the error message as the reason.
hooks: {
onError: (error, request) => {
sentry.captureException(error, {
extra: {
subjectId: request.subject.id,
action: request.action,
resource: request.resource.type,
},
})
},
}
Triggered on:
- Adapter throws (DB connection failure, missing role, etc.)
beforeEvaluatethrows- Internal evaluation throws (rare, indicates a bug)
onError itself should never throw - see "Hook errors" below.
onPolicyError
Fires when evaluation of a single policy throws (malformed rule, bad condition tree, adapter returning garbage). The offending policy is treated as NotApplicable so the rest of the policy set continues to evaluate - one rotten row in the adapter does not crash the request. This hook is the only signal the operator gets that a stored row is broken.
hooks: {
onPolicyError: (error, policyId) => {
sentry.captureException(error, { extra: { policyId } })
log.warn(`duck-iam: policy "${policyId}" skipped during evaluation`)
},
}
Receives the primitive policyId (not the full policy object) and the thrown Error. Fires in both modes. Sub-millisecond cost - wire it.
onMetrics
Lightweight telemetry hook called once per evaluation. Receives a primitive-only IamEngineTypes.IMetricsEvent - no IDecision object, no allocation cost beyond the event itself. Wire it for latency, hit-rate, and outcome metrics in both development and production modes.
import type { IamEngineTypes } from '@gentleduck/iam'
const hooks: IamEngineTypes.IHooks = {
onMetrics: (event) => {
metrics.histogram('iam.duration_ms', event.durationMs, {
action: event.action,
resource: event.resource,
allowed: String(event.allowed),
mode: event.mode,
})
},
}
Event shape (IamEngineTypes.IMetricsEvent):
| Field | Type | Notes |
|---|---|---|
subjectId | string | Subject ID the check ran against. |
action | TAction | Action that was checked. |
resource | TResource | Resource type that was checked. |
allowed | boolean | Final verdict. |
durationMs | number | Wall-clock evaluation time. |
mode | 'production' | 'development' | Engine mode at the time of the call. |
Why a separate hook?
afterEvaluate and onDeny are dev-mode-only: they receive AccessControl.IDecision, which the production fast-path skips allocating. onMetrics runs in both modes because the engine never has to build an IDecision to fire it - only six primitives flow through. Production callers get telemetry without paying the dev-mode allocation cost.
Zero overhead when unwired
The engine guards performance.now() itself on hook presence:
const t0 = this.hooks.onMetrics ? performance.now() : 0
// ...evaluate...
this.emitMetrics(req, allowed, t0) // no-op when hook is undefined
If you never set onMetrics, the only added cost is two boolean checks. No timestamps captured, no event objects allocated.
Production telemetry example
new IamEngine({
adapter,
mode: 'production',
hooks: {
onMetrics: (event) => {
// OpenTelemetry / Prometheus / StatsD - your pick.
otel.recordDuration('iam.authorize', event.durationMs)
otel.recordCounter('iam.decisions', 1, {
outcome: event.allowed ? 'allow' : 'deny',
action: event.action,
})
},
},
})
Execution order
For authorize() / can() / check():
beforeEvaluate -> evaluate -> afterEvaluate -> onDeny (if denied) -> onMetrics
^
onError (on exception) -> onMetrics
onMetrics fires on every terminal path - success, denied, or error - so timing data never drops a request.
For permissions() batch checks:
Each individual check in the batch runs through the full hook pipeline (beforeEvaluate, afterEvaluate, onDeny). Scoped roles are enriched per-check since each check can have a different scope.
For explain() traces:
Only beforeEvaluate is applied. afterEvaluate, onDeny, and onError are not triggered. Explain is a read-only diagnostic tool.
Hook errors
Keep hooks side-effect-only. In the current implementation, a thrown beforeEvaluate, afterEvaluate, or onDeny hook enters the error path and produces a deny result for that check.
If onError itself throws, the surrounding call can reject. So:
- yes
try/catchinside hooks if you don't want errors to affect the decision - yes Use
Promise.resolve().then(() => doThing())for fire-and-forget async work - no Don't
throwfromafterEvaluateto "abort" a request - usebeforeEvaluateto modify the request shape instead - no Don't write blocking I/O in
beforeEvaluate- every check pays the cost
hooks: {
afterEvaluate: async (request, decision) => {
// Defensive: never crash the check
try {
await db.insert(accessLog).values({ /* ... */ })
} catch (err) {
console.error('audit log failed:', err)
}
},
}
Common patterns
Server-side time injection
beforeEvaluate: (request) => ({
...request,
environment: {
...request.environment,
timestamp: Date.now(),
dayOfWeek: new Date().getDay(),
hour: new Date().getHours(),
},
}),
Audit logging
afterEvaluate: async (request, decision) => {
await auditQueue.publish({
type: 'access',
subject: request.subject.id,
action: request.action,
resource: request.resource.type,
allowed: decision.allowed,
reason: decision.reason,
timestamp: decision.timestamp,
})
}
Rate limiting on denies
onDeny: async (request, decision) => {
const denials = await redis.incr(`denials:${request.subject.id}`)
await redis.expire(`denials:${request.subject.id}`, 60)
if (denials > 20) {
await blockUser(request.subject.id)
}
}
Error monitoring
onError: (error, request) => {
sentry.captureException(error, {
tags: {
action: request.action,
resource: request.resource.type,
},
user: { id: request.subject.id },
})
}