Immutable audit trail
A record of who changed what on your platform, which nobody can alter afterwards, including the services that made the changes.
By: Benjamin Clark
Intent
This pattern assumes a platform people administer themselves, through an API that verifies who they are. It makes the audit log immutable by construction: services send events, one writer adds new records, and nothing can update or delete them. This enables three outcomes:
- Nobody can rewrite history, including the services the trail holds to account.
- Every entry reads the same, whichever service sent it.
- A new source can be added without changing the store.
Motivation
Without this pattern, who changed what is left to one of two records, and neither proves much:
- Log lines. They age out, and they prove nothing about who was allowed to do what.
- An audit table that services write directly. It can be edited by the same services it's meant to hold to account.
This pattern rests immutability on permissions and a conditional write, which no change to a producer's code can get around. Each producer commits a change and its event together, as in a transactional outbox, so no action succeeds without its record. One contract means every event reads the same, and a queue such as Amazon SQS between producers and the store lets new sources join with nothing more than permission to send.
Applicability
Use this pattern when:
- People administer a platform themselves, such as creating tenants, changing settings or managing users.
- You need a record of who did what, and when, such as alongside a multi-tenant API, a multi-tenant portal frontend or config as data, where people change what each client gets.
- Every actor signs in through an identity provider, as in multi-tenant Cognito auth, so the actor on each entry comes from a verified token and can't be spoofed.
- Nobody may alter that record afterwards, including the services that made the changes.
Do not use this pattern when:
- The events are high-volume and operational, such as every read or pipeline step, which belong in logs and metrics. The trail is for actions someone must be accountable for.
Structure
Participants
| Participant | Role |
|---|---|
| Administrator | Makes changes through the admin API, and reads the trail through a read-only one. |
| Admin API | Commits an event with each change, with the actor taken from the verified token, and relays it to the queue. |
| Event queue | Decouples producers from the store, with a dead-letter queue for malformed events. |
| Audit writer | Validates each event against one contract, with a closed list of actions, and adds it only if it's new. |
| Audit log | Holds the entries, partitioned by scope and read newest first, with nothing able to update or delete them. It can be kept in any database that supports conditional writes, such as a NoSQL database like Amazon DynamoDB. |
Constraints and trade-offs
- Append-only isn't the same as true. A faulty or compromised producer can still add a false entry, so the trail guarantees that history can't be rewritten, not that every entry is accurate.
- Committing a change and its event together adds a relay to every producer, and an event can then arrive more than once, which the writer treats as success.
- Events arrive after a hop, so a record appears once the queue and writer have handled it, not the instant the action completes.
- The trail only grows, because it has no expiry by design, so old entries need archiving to cheaper storage if volume climbs.
Part of how I deliver
When someone has to answer for who changed what, this is the record I give them. It's one of many patterns I bring through contract delivery, and it extends to new sources as your platform grows.
Is every new client a project?
I build multi-tenant platforms where adding a client is routine, and each client's data stays its own. If you're building one, or yours has become hard to scale, book a call and we'll talk it through.
