BetterIAM
Events and audit

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:

worker.ts
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.onEvent option receives every event, for a single catch-all handler configured with the instance.
  • Plugin afterAudit hooks 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 }.

worker.ts
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 outbox command serves only plugins and events.onEvent.
  • doctor reports 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:

app/audit.ts
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.

OptionTypeDefaultDescription
events.onEvent(event: AuditEvent) => Promise<void> | voidnoneReceives 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 POSTReplaces the webhook transport. Receives { id, tenantId, webhookId, url, event, body, headers } with the signature headers already computed; throw to retry.
events.webhookTimeoutMsnumber10000Timeout of the built-in webhook transport, 1,000 to 120,000 ms.
events.allowPrivateNetworksbooleanfalseLets 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.maxDeliveryAttemptsnumber25Failed webhook (and email or SMS) attempts before a delivery is abandoned.
auditArchiveAuditArchiveOptionsnoneContinuous archiving of every tenant's chain; see audit chain.

Plugins react to events through their afterAudit callback (extensions).

Schema

CollectionHoldsKey fields
auditOne record per event, with the fields in The event.tenantId, action, actorId, resourceId, outcome, timestamp, sequence, hash
auditChainsThe chain head per tenant (record ID = tenant ID).last sequence and hash
auditHooksThe dispatch queue for onEvent, subscribers, and plugin afterAudit. Written only when at least one of them exists.id (the event ID), event, delivered, deliveredAt
outboxPending and past webhook deliveries (kind: 'webhook'), next to email and SMS.to (webhook ID), template (event action), reference (event ID)
webhooksWebhook 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

CodeWhen
INVALID_INPUTaudit.list received an out-of-range limit or offset, or an outcome other than allow or deny.
ACCESS_DENIEDThe caller lacks iam:audit:read on the tenant.
INVALID_CONFIGevents.webhookTimeoutMs is out of range.

Where to go next

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page