> ## Documentation Index
> Fetch the complete documentation index at: https://docs.celum.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Audit log

> Who changed what, when, and what the platform sent back — the record of every mutation, with request and response bodies captured and secrets redacted.

Every request that **changes** something is recorded: who sent it, what it touched, the body it carried, the status it got back, and how long it took. The record lives in Celum's own PostgreSQL, not on any supervisor, so it survives a cluster being rebuilt or removed.

It is at **/audit-logs**, and it is the surface to reach for when the question is "who deleted that?" — or, just as often, "why did that fail, and what exactly did we send?"

## What is recorded, and what is not

**Mutations only.** `POST`, `PUT` and `DELETE` are recorded; a `GET` never is. Reads are the overwhelming majority of traffic and recording them would bury the thing you came to find.

Four categories are deliberately excluded, and knowing them is the difference between "there is no record" and "I looked in the wrong place":

| Excluded | Why |
| - | - |
| `/mcp` | The transport re-dispatches into the real handlers, and **those** are audited. An assistant's action appears as the action it performed, not as an opaque MCP call. |
| `/api/iam/me/tokens` | The mint response contains the token in plaintext exactly once. Capturing it would write a live credential into a table people read. |
| `/api/slack/interactions` | Inbound webhook traffic, not an operator action. |
| Read-only `POST`s | A `POST` is not always a write. Anything ending `/preview`, `/render`, `/check`, `/helm-check`, `/helm-values`, `/branches`, `/status`, `/export`, `/refresh`, `/onboard` or `/reconcile` is skipped by suffix. |

> [!WARNING]
>
> That last rule is a suffix match, not a judgement about the handler. Two real mutations are caught by it today — `POST …/security/onboard` and `POST …/external-secrets/reconcile` — so those actions leave no audit row. Their effects are still visible in component status and in the objects they create; they are simply not in this table.

## What a row holds

| Column | What it is |
| - | - |
| `user_email`, `user_name` | The identity from the session, or from the API token's owner |
| `action` | `create`, `update`, `delete`, `bootstrap`, `onboard` or `refresh` — derived from the method, and refined from the path where a route carries its own verb |
| `resource`, `resource_id` | What kind of thing, and which one. A handler may override the id when the URL does not carry it |
| `detail` | A human line; for updates, the computed field-level diff |
| `method`, `path`, `status_code`, `ip_address`, `duration_ms` | The request as it happened |
| `request_body`, `response_body` | Captured, redacted and capped — see below |
| `stack_trace` | Present only when the handler panicked |
| `created_at` | When |

### Bodies are captured, and secrets are redacted

Both the request and the response body are stored, which is what makes the log usable for debugging rather than only for accountability — you can see the payload that produced a `400`.

Values are redacted by **key name**, case-insensitively, wherever the key contains `password`, `secret`, `token`, `apikey`, `api_key`, `credential`, `privatekey`, `kubeconfigyaml` or `sshpassword`. The value becomes `"***"`. The redaction runs over raw bytes rather than parsed JSON, so it still works on a malformed body — worst case it leaves the input untouched rather than failing open on a half-parsed object.

> [!NOTE]
>
> Redaction keys on the **name**. A secret sent under a neutral key — a certificate body in `content`, a credential in `data` — is not recognised as one and is stored as sent. That is a reason to keep secret material in conventionally-named fields, which is also what the rest of the platform does.

Request bodies are capped at 64 KB and stack traces at 16 KB, each with a truncation marker, so one oversized payload cannot dominate the table.

### Updates carry a diff

Before a `PUT` reaches its handler, the current state of the resource is snapshotted; afterwards the two are compared and the field-level difference is stored. The detail view renders it as `from → to` per field, so an update reads as what actually changed rather than as two JSON blobs to compare by eye.

### Failures are first-class

The status code is stored and filterable. A `4xx` is usually a permission denial or a rejected payload; a `5xx` carries the panic's stack trace when there was one. Filtering to failures is the fastest way to see what an operator tried to do and could not.

## Finding things

The four tiles across the top count, for the window and filters in view:

| Tile | What it counts |
| - | - |
| **Total events** | Every row. Every row is already a write, so this is the number of changes. Its hint names the window, and its trend line always shows the last 24 hours, one point per hour. |
| **Deletes** | Rows whose action is `delete`. A click filters the list to them. |
| **Failures (4xx / 5xx)** | Rows whose status is 400 or above. A click filters the list to them; a second click clears the filter. |
| **Unique actors** | Distinct emails, with the most active one named. |

With no window chosen, the page covers **all time**, and the time picker says so.

| Filter | Values |
| - | - |
| Time | All time (the default), last hour, last 24 hours, last 7 days, last 30 days, or an explicit `from`/`to` |
| Resource | Whatever appears in the window, with counts |
| Action | `create`, `update`, `delete`, `bootstrap`, `onboard`, `refresh` |
| Status | Success (2xx), client error (4xx), server error (5xx), or failed (4xx and 5xx together) |
| User | An actor's email |
| Free text | `q` searches across the row |

Listing is paged — 50 rows by default, 200 at most per request.

The page does not refresh by itself unless you ask it to. **Auto-refresh every** under the list re-reads it every 15, 30 or 60 seconds. **Live tail** beside Export re-reads it every 15 seconds while it is on.

## Export

Export honours **the filters currently applied** and returns up to 10 000 rows, as `csv` (the default) or `json`. It is a download, not a report: use it to hand a window to someone who does not have access to the platform, or to keep a copy of an incident before the window scrolls past.

> [tab] UI
>
> **/audit-logs** — tiles, filter sidebar, timeline, Live tail and auto-refresh, and a detail drawer per row with the diff and both bodies.

> [tab] API
>
> ```text theme={null}
> GET /api/audit-logs?resource=&user=&action=&status=&q=&from=&to=&limit=&offset=
> GET /api/audit-logs/stats
> GET /api/audit-logs/export?format=csv|json
> ```

> [tab] MCP
>
> `query_audit_logs` and `get_audit_stats` — the same filters, for an assistant asked "what changed on this supervisor yesterday".
>
> **There is no export tool.** An assistant can read and summarise the log; it cannot produce a file.

## Permissions

| Task | Action | KRN |
| - | - | - |
| Read, filter and export the audit log | `audit-log:List` | `krn:celum:audit-log:*` |

There is one action, and it covers reading the log in every form. **Nothing writes to it through the API** — rows are produced by the middleware as a side effect of other work, and there is no route that edits or deletes one.

## Retention

Celum never deletes audit rows. There is no purge job and no retention setting: the table grows for as long as the database keeps it, which is the right default for a record whose value is that it was not tidied up. It is also typically the largest table in the control-plane database, so it is the one to watch when sizing storage or taking a [database backup](https://docs.celum.io/administration/settings.md#database-backup).

## What commonly goes wrong

| Symptom | Cause and fix |
| - | - |
| The page returns `503` and nothing is recorded | No `DATABASE_URL`. The audit log is database-backed; without one the middleware skips recording entirely and the endpoints refuse. |
| An action definitely happened and there is no row | Either it was a `GET`, or its path ends in one of the skipped suffixes — check the exclusion table above before concluding anything was lost. |
| An assistant's action is missing under "MCP" | It is there under the action it performed. `/mcp` itself is never recorded because it re-dispatches. |
| A token's own creation is not in the log | Self-service token mint is excluded on purpose — the response body is the credential. The token's *use* shows up as the actions it takes. |
| A field you expected to be redacted is in the clear | Redaction matches key names. A secret under a neutral key is not recognised. |

## Related

> [card] [Settings](https://docs.celum.io/administration/settings.md)
>
> API tokens, database backup, and the rest of the platform-wide configuration.

> [card] [Permissions model](https://docs.celum.io/concepts/permissions-model.md)
>
> How a denial is decided — the `4xx` rows in this log are its output.

> [card] [Permissions reference](https://docs.celum.io/reference/permissions.md)
>
> Every action, including `audit-log:List`.

> [card] [Celum AI and MCP](https://docs.celum.io/concepts/celum-ai.md)
>
> Why an assistant's work appears under its real actions rather than as MCP calls.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.