CodamAIDocs
Topicdone

One trail for everything

A single listener writes every business event into a table that only grows. What an entry looks like.

Variants
event with a logged-in callerevent without a callerlong text is cutwriting failschange is rolled back

What this is about

An audit is a record of who did what and when. In CIAS it concerns people and permissions: a tenant was created, a person suspended, a role granted. An auditor later wants to read exactly that, in one place and in one order.

The picture

flowchart LR
    T["cias-tenancy<br/>TenantEvent"] --> L
    U["cias-user<br/>UserEvent"] --> L
    A["cias-authorization<br/>AuthorizationEvent"] --> L
    R["cias-registration<br/>RegistrationEvent"] --> L
    L["Listener<br/>DomainEventAuditListener"] -- "+ who is acting,<br/>from the token" --> DB[("cias_audit_entry<br/>system database")]

Read it like this: every module reports its events without knowing that anyone is listening. The listener listens to every business event, not to a list of selected ones. If a module reports a new kind of event tomorrow, it ends up in the audit without any further work.

Why one trail and not four

  • An auditor reads one story. Four tables that have to be put together by timestamp would be a reconstruction. That is exactly what an audit is meant to make unnecessary.
  • A module does not record itself. If every module wrote its own entries, the code whose behavior is being checked would also write the record about it.
  • The trail lives in the system database, not in a tenant database. Some events concern tenants that do not have a database of their own yet, and whoever reads the audit reads across all tenants.

What an entry looks like

FieldContentExample
idthe ID of the entry9b1e…
typethe kind of event, as Group.KindTenantEvent.Suspended
detailthe event as it describes itself: all fields with valuesSuspended[tenantId=4f2a…, tenantKey=nordbau, occurredOn=2026-09-22T09:14:03Z]
actorIdwho acted: the ID of the account in Keycloak (sub from the token)5c9e…
actorTenantthe tenant the request ran under, if there was onenordbau
occurredOnwhen it happened in business terms2026-09-22T09:14:03Z
recordedOnwhen the entry was written2026-09-22T09:14:03.120Z

What happened comes from the event. Who acted is read by the listener from the token of the running request. The two points in time are normally milliseconds apart. They differ exactly when it matters.

When the entry is written

Only after the change has been committed. A module reports its event while it is still writing the change. The listener waits until that transaction is complete, though, and then writes the entry in a transaction of its own.

Two rules follow from this:

  • The audit never decides whether a change happens. If the entry cannot be written, the change stays in place anyway. There is a gap, and the log shows it.
  • There is no entry for something that did not happen. If a change is rolled back, the listener writes nothing.

If no transaction is running, for example with a timer, the listener writes at once.

The variants

How an entry comes about

When: A platform administrator suspends a tenant through the admin API.

The listener reads sub and tenant from the token and records them as actorId and actorTenant.

Result: entry with an actor

When: A timer lets a time-limited role expire, the reconciliation runs at startup, or a person registers themselves without being logged in.

There is no token. actorId and actorTenant stay empty. Empty means: the platform acted on its own, or nobody was logged in. CIAS deliberately writes no placeholder like system: it would look like an account with that name.

Result: entry without an actor

When: The event describes itself with more than 4000 characters.

CIAS cuts detail and appends …[cut]. An event that happened should not be missing just because its text was long. The marker shows that it is not the whole story.

Result: entry with cut text

When: The database does not accept the entry.

At this point the change is already committed and stays in place. CIAS writes an error to the log, with the kind of event but without its text. The text could contain customer data, and the log is kept elsewhere.

Result: gap in the trail, visible in the log

When: The module has reported its event, then the change fails and is rolled back.

The listener writes nothing. An entry would testify to an act that never took place.

Result: no entry

Append only

There is no function that changes or deletes an entry, neither in Java nor over HTTP. A record that can be corrected afterwards answers a different question from the one an auditor asks.

The database itself does not enforce this. Whoever wants to enforce it there too, for example with a trigger or by taking away the application’s right to update, decides that for their own database.

Switching it on

The audit belongs to the module cias-audit:

SettingMeaning
codamai.cias.audit.enabledtrue: listener and read access are there
codamai.cias.audit.persistencejpa: entries go into cias_audit_entry

Standalone CIAS switches both on, and so does the hub-backend with embedded CIAS. There the table lives in the system database, and the entry is written, as everywhere, only after the change has been committed. Any other embedded installation only has the audit if it includes the module and switches it on.

Pitfalls

Next

Sources in the code and the knowledge base
  • CIAS/cias-audit – DomainEventAuditListener (on, entryFor, typeOf, caller), AuditEntry (ofSystem, ofCaller, MAX_DETAIL, CUT), AuditTrail, JpaAuditTrailAdapter.append (REQUIRES_NEW), @TransactionalEventListener AFTER_COMMIT
  • CIAS/cias-audit – db/migration/cias-audit/V1__cias_audit_entry.sql, CiasAuditConfiguration (codamai.cias.audit.enabled, persistence)
  • CIAS/cias-kernel – DomainEvent (occurredOn), CallerContext (subject, tenantKey)
  • CIAS/cias-runtime – application.yml (codamai.cias.audit)
  • CIAS/cias-audit/docs/adr – ADR-033
Search