Events and audit
Every audit record is an event, recorded in the transaction that made the change and fanned out to subscribers, webhooks, and the audit chain.
Your application and your security team both need to know when access changes. The application may sync a new member to a billing system or invalidate a cache when a role changes; the security team wants every administrator elevation in its SIEM and an alert on repeated failed sign-ins. And auditors want a record of all of it that nobody can quietly edit.
Better IAM serves all three from one source: the audit log. Every audit record is also an event you can react to.
Each of these produces one audit record inside the transaction that made the change: provisioning operations,
authentication events (auth:session:create, auth:mfa:enable, and so on), denials, root overrides, invitation
redemptions, access-request decisions, and deployment operations.
The record is appended to the tenant's tamper-evident and fanned out in that same transaction. So nothing is emitted for a change that rolled back, and nothing committed is lost.
You can consume events three ways:
- In-process subscribers run your code in the application for matching events. Use them to update caches, metrics, or other systems in the same codebase.
- send signed HTTPS requests to an endpoint. Use them for SIEMs, alerting, and services outside your application.
- The audit log answers questions after the fact, such as "who changed this role last week?".
There is one deliberate exception to "nothing is emitted for a change that rolled back". auth:signin:fail
records a wrong password, factor, or recovery code presented for a real account. The refused sign-in rolled back,
so the event is appended in a transaction of its own, with metadata.reason, metadata.ip, and
metadata.userAgent. That makes it a good subscription for brute-force alerting.
The event
Every event has the same shape, whichever way you receive it:
Prop
Type
Audit records omit passwords, keys, and token bodies, so nothing that consumes events ever receives them. The
audit chain page explains sequence, previousHash, and hash, and the
lifecycle events page lists the metadata of the access lifecycle events.
In-process subscribers
When the reaction lives in your own codebase, subscribe to events directly instead of running a webhook endpoint.
iam.events.subscribe(patterns, handler) registers a handler for events whose action matches any of the patterns
and returns a function that unsubscribes it:
const stop = iam.events.subscribe(['iam:identities:*', 'access-request:*'], async (event) => {
await metrics.count(event.action, { tenant: event.tenantId, outcome: event.outcome });
});
// Later
stop();- Patterns use the policy glob syntax (
*and?) and may be a single string or a list. - The
events.onEventoption receives every event, for a single catch-all handler configured with the instance. - Plugin
afterAudithooks share the same queue, so plugins react to events the same way.
Dispatch
Handlers do not run inside the request that caused the event. If they did, a slow or failing handler could delay or break the operation that already committed. Instead the event is queued, and a dispatcher delivers it.
Call iam.events.dispatch() (the same function as iam.dispatchAuditHooks()) from your worker schedule, next to
iam.auth.dispatchOutbox(), which delivers emails and webhooks. It runs the queued events through every handler
and returns { dispatched }.
setInterval(async () => {
await iam.events.dispatch();
await iam.auth.dispatchOutbox();
}, 60_000);- Dispatch is at least once. A handler that throws leaves its row queued for the next run, so handlers must be
idempotent by
event.id. - Subscribers live in memory, so dispatch in the process that registers them. The CLI
outboxcommand serves only plugins andevents.onEvent. doctorreports audit hooks that have waited more than 15 minutes, a sign that nothing is dispatching.
Framework integrations wrap this. In NestJS, @OnIamEvent('identity:*') subscribes a provider method, and
dispatchIntervalMs runs dispatch inside the app on a timer; for multi-instance deployments, dispatch from a
single worker. See NestJS and background jobs.
Query the audit log
For questions after the fact, such as a member's activation history or last week's denials, read the log.
audit.list (iam:audit:read) returns a tenant's events newest first, filtered as you need:
import { iam } from './iam';
const denials = await iam.api.audit.list(credential, {
tenantId,
action: 'iam:bindings:*', // exact name or glob
outcome: 'deny',
from: Date.now() - 7 * 86400_000,
limit: 100,
});Route: POST /audit/list
limit is 1 to 1000 (100 by default) with an offset (up to 1,000,000). action accepts an exact name or a glob
pattern; actorId, resourceId, and outcome match exactly; from and to bound the timestamp. To verify or
export the log as evidence, use audit.verify and audit.export (audit chain).
Options
Event delivery is configured under events in the server options. Delivery retries come from authentication.
| Option | Type | Default | Description |
|---|---|---|---|
events.onEvent | (event: AuditEvent) => Promise<void> | void | none | Receives every committed event from iam.events.dispatch(), at least once. Also runs from the CLI outbox command. |
events.deliverWebhook | (delivery: WebhookDelivery) => Promise<void> | built-in HTTPS POST | Replaces the webhook transport. Receives { id, tenantId, webhookId, url, event, body, headers } with the signature headers already computed; throw to retry. |
events.webhookTimeoutMs | number | 10000 | Timeout of the built-in webhook transport, 1,000 to 120,000 ms. |
events.allowPrivateNetworks | boolean | false | Lets webhook URLs reach private and reserved addresses (for example 10.0.0.0/8). Loopback over http is allowed only while baseURL is http. |
authentication.maxDeliveryAttempts | number | 25 | Failed webhook (and email or SMS) attempts before a delivery is abandoned. |
auditArchive | AuditArchiveOptions | none | Continuous archiving of every tenant's chain; see audit chain. |
Plugins react to events through their afterAudit callback (extensions).
Schema
| Collection | Holds | Key fields |
|---|---|---|
audit | One record per event, with the fields in The event. | tenantId, action, actorId, resourceId, outcome, timestamp, sequence, hash |
auditChains | The chain head per tenant (record ID = tenant ID). | last sequence and hash |
auditHooks | The dispatch queue for onEvent, subscribers, and plugin afterAudit. Written only when at least one of them exists. | id (the event ID), event, delivered, deliveredAt |
outbox | Pending and past webhook deliveries (kind: 'webhook'), next to email and SMS. | to (webhook ID), template (event action), reference (event ID) |
webhooks | Webhook subscriptions. | url, events, outcomes, resources, scope, active |
Delivered auditHooks rows and old outbox deliveries are deleted by iam.sweepExpired(). Audit events are kept
until you prune them (audit chain); tenant purges keep
them too.
Errors
| Code | When |
|---|---|
INVALID_INPUT | audit.list received an out-of-range limit or offset, or an outcome other than allow or deny. |
ACCESS_DENIED | The caller lacks iam:audit:read on the tenant. |
INVALID_CONFIG | events.webhookTimeoutMs is out of range. |
Where to go next
Better IAM is created by Sean Filimon
Last updated
Data and consistency
How Better IAM stores its records, why every write is serialized, how side effects leave through the outbox, and how plugins and protocols compose.
Webhooks
Signed HTTPS deliveries of audit events with outcome and resource filters, retries with backoff, delivery history, and redelivery.