The audit trail
Every state-changing action, who did it, from where, and what it touched.
Every state-changing action on the install leaves a row: who did it, when, from which IP, on what, and whether it succeeded. Admin → Audit, gated by the audit:view platform permission.
What is recorded
Every POST, PATCH, PUT and DELETE that reaches the API. Not a list of instrumented endpoints — a floor that a new module cannot forget to opt into, because it is enforced above the controllers rather than inside each one.
An entry carries:
| Field | Holds |
|---|---|
| Actor | The signed-in user — or empty for a system or anonymous event, such as a failed login on an address matching no account. |
| Action | A dotted name derived from the whole route, not the verb alone: application.restart, application.env.update, user.role.change. |
| Resource | What it touched, and that object’s id. |
| Target user | Who it was performed on, when that differs from the actor. |
| Origin | IP address, user agent, and the route called. |
| Outcome | Success, or the failure with its status code and message. |
Failures are recorded too, and they matter more than successes. A rejected request never reaches the controller, so it is captured before that point — a burst of 403s from one address on one resource is the shape of someone mapping out what they can reach, and it would otherwise leave no trace at all.
What is deliberately left out
- Reads. A
GETchanges nothing, and logging every one would bury the mutations under machine traffic. - Machine chatter — token refresh, agent heartbeats, health checks, marking a notification read. High volume, no signal.
- Flood rejections. A banned IP is refused on every request; the ban itself is already recorded once. Rate-limit
429s are the limiter working, not an operator action.
Request bodies and secrets
The body of a mutation is the interesting part — it says *what* changed, not merely that something did. It also carries passwords, tokens, private keys and environment values, so field names matching a secret pattern are redacted before the entry is stored, and the payload is capped in size.
code would have swallowed exitCode, and exitCode: 137 is the out-of-memory kill signature — exactly the detail the trail exists to preserve. Over-redacting starves the log as effectively as under-redacting leaks from it.Reading the trail
The table filters on actor, target user, action, resource, resource id, IP address, free text, a date range, and failures only. The action list is offered from the names actually present, so you filter on what your install really does rather than on a fixed menu.
The three questions it answers fastest:
- “Who broke production at 14:12?” — a date range plus the application’s resource id.
- “Is someone probing us?” — failures only, grouped by IP.
- “What did this contractor touch?” — filter by actor, over their whole engagement.
Entries are kept for 365 days by default and pruned hourly in small batches. The retention is a platform setting — an audit table that grows without bound becomes a disk problem long before it becomes a useful archive, and one unbounded delete would lock the table against every mutation on the install.
Calls made with an API key appear like any other, attributed to the key’s owner, with the route recorded. A key is not a way to act without leaving a trail.