# Audit Log: Hash Chain, Retention and Forwarding

> nauthera-server's audit log: the event catalog, volume levels, retention, the append-only role, the signed hash chain, forwarding and the query and ranking API.

nauthera-server records security-relevant activity to an `audit_events` table in its own
database. Each record names its actor and outcome and, where they apply, its organization,
target, client, session, and request and trace IDs. The set of actions is **closed**: the
catalog is part of the API contract and can be read at `GET /v1/admin/audit-catalog`,
with the same scope and permission as the records (see [Querying](#querying)).

Every action, with its level, severity and the metadata it may carry, is listed in
[Audit Events](https://www.nauthera.io/docs/v1/audit-events). The main areas:

| Area | For example |
|---|---|
| Authentication | `auth.sign_in.completed`, `auth.registration.completed`, `auth.account.locked`, `auth.password_reset.completed` |
| Sessions | `session.created`, `session.revoked` |
| MFA | `mfa.totp.activated`, `mfa.passkey.registered`, `mfa.email.verified`, `mfa.recovery_code.consumed` |
| OAuth and OpenID Connect | `oauth.token.issued`, `oauth.consent.decided`, `oauth.device.decided`, `oidc.logout.token.delivered` |
| Users | `user.created`, `user.password.changed`, `user.suspended`, `user.deleted` |
| Organizations and clients | `organization.updated`, `client.registered`, `client.applied`, `identity_provider.applied` |
| Admin, edge and audit | `admin.access.denied`, `role.granted`, `http.request.screened`, `audit.log.read` |
| System | `system.started`, `system.keys.loaded`, `system.audit.checkpoint`, `system.dependency.degraded` |

`auth.sign_in.alerted` records the email sent to a user after a sign-in from a browser or
network the account has not used before.

## What a record says

Beyond its action, actor, target and outcome, a record carries what the server could
tell about the request:

- **Who sent it.** The read API labels each record with `client_kind`, what kind of
  software the request's User-Agent claims to be (`browser`, `ai_agent`, `crawler`,
  `automation`, `library` or `unknown`), and `client_agent`, the product it names for
  anything but a browser, such as `ClaudeBot`, `Claude Code` or `curl`. They are read
  from the stored `user_agent` each time a record is served, so older records are
  labelled too and the hash chain covers nothing new. A sender writes its own
  User-Agent, so both are a claim: nothing is refused or allowed on them. A record no
  request produced, or a coalesced summary, carries neither. They are not a filter and
  are not exported; an export carries the `user_agent` they are read from. The
  [request log](https://www.nauthera.io/docs/v1/observability#logs) carries the same two fields.
- **The sign-in's network and second factor.** Every sign-in record, completed or
  refused, carries `network_zones`: the names of the
  [network zones](https://www.nauthera.io/docs/v1/mfa#network-zones) its address fell in. One by a user who
  holds a second factor and was not asked for it carries `mfa_spared`:
  `remembered_browser` or `network_zone`. One through an identity provider set to
  `spareSecondFactor` carries `mfa_spared: identity_provider`, whether or not the user
  holds a factor. One that some level's second-factor
  [rules](https://www.nauthera.io/docs/v1/mfa#rules) applied to carries `mfa_rules`, each rule's name after
  its level (`deployment:`, `organization:`, `client:`).
- **The client and session.** Every record a sign-in makes, the session it creates and
  the new-sign-in alert included, carries the `client_id` the sign-in was started for,
  as a claim (`claimed_client_id`), never as the verified client. An administrator's
  event names, in the client column, the admin client their token was minted for, the
  organization's console or machine client, and the session the token was issued in;
  a client being managed stays the target.

## Levels

`audit.level` sets how much is recorded. Each level includes everything in the levels
before it:

| Level | Records |
|---|---|
| `off` | Only the events that cannot be suppressed |
| `security` | Authentication outcomes, credential and MFA changes, privilege grants, and every administrative change |
| `write` (default) | Every other state change, such as updating a user or renaming an organization |
| `read` | Reads too: list and get endpoints, UserInfo, introspection |
| `trace` | High-volume protocol traffic, mainly token issuance and refresh |

The levels describe successes. A refusal or a server fault is recorded at `security`
whatever its event's level, so at the default every refused token request is kept and no
issued one is.

Fine-tune with globs over action names (`*` also matches across dots):

```yaml
audit:
  level: write
  include: ["oauth.token.issued"]      # record these regardless of level
  exclude: ["mfa.status.read"]         # never record these; beats level and include
```

A pattern in `include` or `exclude` that matches no action in the catalog is a **startup
error**, so a typo cannot silently do nothing.

Refusals that anyone can send at line rate, such as requests over the rate limit, bodies
that are too large or repeated headers, are coalesced per caller, endpoint and rule: the
first is recorded in full, and the rest of `audit.coalesce_window` (1m) becomes one
summary record carrying `repeat_count`, `first_seen_at` and `last_seen_at`.

## Events that cannot be suppressed

Some events are recorded whatever `level` and `exclude` say, among them sign-ins,
deleting a user, changing a password, disabling MFA, granting and revoking a role,
forgetting a user's remembered browsers, revealing a client secret, reading the audit
log, and dropping an expired audit partition. The catalog marks each one as `mandatory`. An operator who could switch off the record of their own
destructive actions would have a privilege-escalation path. Only `audit.enabled: false`
stops these events, because it stops all recording, and even then the server records
that it was turned off. At startup the server logs exactly which events the resolved
policy suppresses.

## Delivery and durability

- No request waits on the database. Every event is queued: mandatory events have a queue
  of their own (`priority_buffer_size`) that is written as soon as events arrive, and
  everything else is written in batches (`buffer_size`, `batch_size`, `flush_interval`).
  Anonymous callers may fill at most half of the mandatory queue, and its last eighth is
  kept for administrators and the server itself, so a flood of failed sign-ins cannot
  crowd out the record of an administrator's change.
- A full queue drops rather than blocks. Every drop is counted in
  `nauthera_audit_events_dropped_total` and reported as a `system.audit.overflow` record
  that carries the count. That record is a [platform record](#querying), so through the
  API only `platform.audit` holders see it. Alert on `nauthera_audit_buffer_depth` before
  drops start.
- Mandatory events the database refuses are held and retried until it takes them, and
  reported as `system.audit.write_delayed`. When the database keeps refusing them, the
  server records `system.audit.degraded`, marks its `audit` readiness check degraded (the
  replica stays in service), and records `system.audit.recovered` once writes succeed
  again.
- `audit.fail_closed: true` refuses privileged changes, the admin API's user and
  organization changes, organization changes over the gRPC control plane and
  second-factor changes, with a `503` while the pipeline is degraded. The refusal comes
  before the change, so nothing is left half-done. Sign-in, registration, token requests
  and other anonymous endpoints are never refused for the audit log's health. With it
  on, startup also waits until its bootstrap records are written. It is off by default.
- On shutdown, the queues are drained after the listeners stop and before the database
  closes.

## Retention

`audit.retention` defaults to `9600h` (400 days). That covers PCI DSS 10.5.1's twelve
months with a month of slack. The table is partitioned by month,
`partition_ahead_months` (6) partitions are created ahead of time, and whole partitions
are dropped once they expire. Each one is recorded as `system.audit.partition_created`
or `system.audit.partition_dropped`.

## The append-only role

By default the server maintains its own schema, so the role it connects as owns
`audit_events` and could change or delete history.
`audit.partition_maintenance: external` takes migrations and partition maintenance out of
the server, and `nauthera-server maintain`, run as a separate job under the owning
credential, does them and exits. Name the server's own role in `database.runtime_role`,
and each `maintain` run grants it `SELECT` and `INSERT` on the audit log and nothing on
any partition. A server running as that role cannot change or remove a stored record.

With maintenance external, the server refuses to start while a migration it ships is
unapplied or no partition covers the current month, and warns while a partition for a
coming month is missing. Every boot logs one warning for each way its own
role could still rewrite the log, and records the list as `audit_rewrite_routes` on
`system.audit.configured`.

The Helm chart sets all of this up with `appendOnlyAudit`. See
[Installation](https://www.nauthera.io/docs/v1/installation#migrations-and-database-maintenance).

## Tamper evidence

Every row is linked in a SHA-256 hash chain, one segment per writer and month. Every
`audit.checkpoint_interval` (1m), each writer signs a **checkpoint** of how far its
segments reach, with the Ed25519 key at `audit.checkpoint_key_path`. A checkpoint is
recorded in the log as `system.audit.checkpoint` and written whole to the application log
at `info` as `audit chain checkpoint`, so your log shipping carries a copy off the host.

```bash
openssl genpkey -algorithm ed25519 -out checkpoint.pem
```

Use a key of its own, never a token-signing key, and keep its public half where the
database's administrators cannot write. Without a key the checkpoints are unsigned and
the server warns at startup; `audit.checkpoint_key_required: true` refuses to start
instead. A configured key that cannot be read fails startup.

The Helm chart provides the key. It sets `audit.checkpoint_key_path` to the first of: a
path you set yourself, a key you supply in `secrets.auditCheckpointKey`, or, with
`certManager.enabled`, an Ed25519 key cert-manager issues
(`certManager.auditCheckpointKey`, on by default) and every replica shares. The issued
key is never rotated, and its public half is in the Secret's `tls.crt`, for the verifier
to trust. The key is mounted into the server's pods only. The chart's production profile
sets `audit.checkpoint_key_required`, which the chart also checks when it renders. See
[Installation](https://www.nauthera.io/docs/v1/installation#certificates).

Checkpoint rows are written about once a minute whenever anything was written, an
administrator's own reads included, so the list, tail and counts leave
`system.audit.checkpoint` out unless the `action` filter matches it. A record is still
read by its id.

`nauthera-server audit verify` checks one month against the checkpoints, from both
copies, reading the database read-only:

```bash
nauthera-server audit verify --config /etc/nauthera/config.yaml \
  --month 2026-10 \
  --trusted-keys trusted-keys.json \
  --app-log-checkpoints checkpoints-2026-10.jsonl \
  --retention 400d
```

It trusts only the public keys in the trusted-keys file you give it, current and retired,
never a key the log names. It reports rows that were changed, deleted, forged or
reordered, a row filed under a month its segment does not belong to (`misplaced`), a
truncated tail, a deleted segment and missing or conflicting checkpoints.
It exits `0` with no finding, `1` with any, and `2` when it could not verify. Run it
monthly and after anything that touched the database outside the server. The server's
`docs/CONFIGURATION.md` has every flag, the trusted-keys format, key rotation and what
each finding means.

## Forwarding

`audit.export` forwards the stored log, every organization's records and the platform's,
in the order they reached the database. Forwarding follows the database, so an exporter
that is down only falls behind: it never delays a request or drops an event. Each
exporter keeps its position in the database and resumes from it, every replica runs the
forwarder with a lease so one moves each exporter, and delivery is at least once:
de-duplicate on `id`.

The exporter built in appends each record as one line of JSON to a file, for a log
shipper to read:

```yaml
audit:
  export:
    file:
      enabled: true
      path: /var/log/nauthera/audit.jsonl   # rotate with copytruncate
```

Each record is a JSON object with `schema_version: 1` and every column of the stored
record. Watch `nauthera_audit_export_lag_seconds` and
`nauthera_audit_exported_records_total`.

## Querying

```bash
curl -s "https://auth.example.com/v1/admin/orgs/default/audit-events?action=user.*,mfa.*&outcome=failure&limit=100" \
  -H "Authorization: Bearer $TOKEN"
```

This needs the `nauthera.audit:read` scope and the `audit.read` permission, on a token
issued to one of the organization's reserved admin clients, `{slug}-admin-console` or
`{slug}-admin-m2m`; a token from any other client is refused. In the organization where
they are held, `org-auditor` and `org-owner` grant the permission. `platform-auditor` and
`platform-owner`, held in the main organization, grant it in every organization. A
`{slug}-admin-m2m` token can read its organization's log, because that client holds
`org-auditor`. See [Getting a token](https://www.nauthera.io/docs/v1/user-management#getting-a-token).
Dynamic registration refuses `nauthera.*` scopes.

Platform records name no organization: the `system.*` events, and refusals recorded before
a request's organization was resolved. Only a caller holding `platform.audit`, which
`platform-auditor` and `platform-owner` grant in the main organization, sees them,
beside the records of whichever organization it queries.

Reading the log is itself recorded, as `audit.log.read`, naming whose trail was read.

Filters:

| Parameter | Meaning |
|---|---|
| `from` and `to` | RFC3339 window, at most 90 days wide; a wider one answers `400`. Defaults to the last 30 days. |
| `action` | Comma-separated action patterns (`*` and `?` wildcards). |
| `category`, `outcome`, `severity`, `reason`, `actor_type`, `operation` | Comma-separated values from the catalog's vocabularies. |
| `actor_id`, `target_id`, `target_type` | Who did it, and to what. |
| `client_ip`, `client_net` | An address, or the IPv4 `/24` or IPv6 `/64` it lies in. |
| `network_zone` | Comma-separated [network zones](https://www.nauthera.io/docs/v1/mfa#network-zones), at most 16: records whose address fell in one of them. Sign-ins record their zones. |
| `client_id` | The client the server resolved, or the one the caller only named, which the record carries as `claimed_client_id`. |
| `session_id`, `request_id`, `trace_id` | Correlation. `session_id` is the session's public `sid`. |
| `cross_tenant_only` | Only actions taken on this organization by an actor from another one. |

Results use the same cursor pagination as the rest of the
[admin API](https://www.nauthera.io/docs/v1/user-management#pagination-and-sorting) and can be sorted by `id`
or `occurred_at`. `GET /v1/admin/orgs/{slug}/audit-events/{id}` returns a single record.

A page through a time window cannot follow the log as it grows, because a record can
become visible after records that occurred later. To follow it, poll
`GET /v1/admin/orgs/{slug}/audit-events/tail`, which reads in the order records reached
the database: keep the `next_cursor` every response carries, read again at once while
`has_more` is true, and pause when it is false.

`GET /v1/admin/orgs/{slug}/audit-events/counts` counts records per time bucket (one
minute, ten minutes, an hour or a day), optionally grouped by one column, under the same
filters. It needs the `nauthera.insights:read` scope and the `insights.read` permission.

### Ranking

`GET /v1/admin/orgs/{slug}/audit-events/top` returns the subjects behind the records the
filters match, by how many records each caused, most first: `by=actor`, `target`,
`client` (resolved or only named, as the `client_id` filter matches), `client_ip` or
`network_zone`. A record counts once under each zone it names, and once under the empty
key, outside every zone, when it names none. It takes the list's filters, at most 25
subjects come back, and the window spans at most 31 days. Reads of the audit log itself
are left out unless `include_audit_reads` is set.

```bash
curl -s "https://auth.example.com/v1/admin/orgs/default/audit-events/top?by=client_ip&action=auth.sign_in.completed&outcome=failure" \
  -H "Authorization: Bearer $TOKEN"
```

It needs `nauthera.audit:read` and `audit.read`. Ranking or filtering by address also
needs `users.pii.read`; without it an actor's or target's display name is withheld and
the id alone is returned. A zone name is not personal data, so ranking by zone needs
`audit.read` alone. Answers are cached for at least the dashboard's shortest refresh
interval, and a ranking past the aggregate deadline is answered `503` `busy` with
`Retry-After`. Reading one is recorded as `audit.ranking.read` at the `trace` level.

The [admin dashboard](https://www.nauthera.io/docs/v1/admin-dashboard) shows the log with its filters, a
volume chart and who sent each request, and its
[threat page](https://www.nauthera.io/docs/v1/admin-dashboard#threat-analytics) ranks who is behind the
refused sign-ins. The Docker Compose stack also provisions a Grafana **audit-log explorer**
dashboard that reads `audit_events` directly from PostgreSQL (`/d/nauthera-audit`).

## Known gaps

- Client and branding changes made over the gRPC control plane are recorded only when
  they are refused.
- Without `database.runtime_role`, the server's role can still rewrite the log. The hash chain and checkpoints make such a change evident to
  `audit verify`; they do not prevent it.
- No endpoint exports a long range in one request. The tail walks the whole retained log
  one organization at a time, and `audit.export` forwards all of it.
- Checkpoints are signed with a key file, not a key held in a KMS; exported records do
  not carry their hash-chain link; and `audit verify` runs only when you run it, with no
  scheduled check or alert built in.
