Skip to content

Recipe: Stream Audit Events

Problem: compliance wants IAM’s audit trail in the central SIEM, not locked in an application table. You need a stream — without risking the business request when the broker hiccups.

IamAuditSink is a fun interface: one method, one bean, done.

@Component
class KafkaAuditSink(
private val kafka: KafkaTemplate<String, String>,
private val objectMapper: ObjectMapper,
) : IamAuditSink {
override fun onAuditEvent(event: IamAuditEventView) {
val payload = objectMapper.writeValueAsString(
mapOf(
"tenantId" to event.tenantId,
"actor" to event.actorEmail,
"entityType" to event.entityType,
"entityId" to event.entityId,
"action" to event.action,
"oldValue" to event.oldValue, // JSON snapshot, if captured
"newValue" to event.newValue,
"correlationId" to event.correlationId,
"at" to event.createdAt,
)
)
kafka.send("iam-audit", event.tenantId.toString(), payload)
}
}

Sinks receive IamAuditEventView — a flat, framework-free projection, never IAM’s persistence types. IamAuditSink/IamAuditEventView ship in the contract module iam-api, so a sink-only module needs just implementation("dev.mpofusindie:iam-api"). Register as many sinks as you like; each is invoked independently.

Mind the view’s honest types when you map it: entityId is nullable, oldValue/newValue/ metadata are JSON strings (parse, don’t re-serialize), and correlationId is a free-form String, not a UUID — parse it leniently.

  • The table stays the durable record. Sinks are a tap in addition to the default JPA iam_audit_logs table; registering one changes nothing about default behavior.
  • After commit only. Delivery defers to afterCommit when the audit write is transactional — a sink never sees an event whose transaction rolled back.
  • Best-effort, isolated. A throwing sink is caught and logged per sink; it can neither break the business request nor starve other sinks.
  • At-most-once, in-process. No retries, no crash-recovery between commit and delivery. Need guaranteed delivery? Poll iam_audit_logs as your outbox — it is the transactionally-written record.
  • Not atomic with the business change. Delivery runs after the business transaction commits, so a sink executes with no ambient transaction — a DB-writing sink must open its own unit of work (e.g. a PROPAGATION_REQUIRES_NEW TransactionTemplate); it cannot enlist in the business transaction. A sink failure loses only the mirror (the durable iam_audit_logs row already exists), so never treat a sink mirror as system-of-record without a reconciliation job against iam_audit_logs.
  • Threading follows the audit write — synchronous audits deliver on the request thread. Sinks doing slow I/O should hand off internally (as KafkaTemplate.send already does).

Every request gets a correlation id: an inbound X-Correlation-Id header is reused (so a gateway/mesh id survives end-to-end) or a UUID is generated; either way it’s echoed on the response, stamped into Problem-Details error payloads, and delivered on event.correlationId. To see it in your own logs, add it to the pattern:

logging:
pattern:
level: "%5p [%X{iam.correlation_id}]"

One id now joins the user-visible error, the server log line, the audit row, and the SIEM event. For the full picture — where the id comes from and the metrics IAM emits alongside it — see the Observability guide.