GitHub

Audit log

Every state-changing API action is recorded to an audit trail — the answer to "who banned 203.0.113.66 at 03:14, and who reloaded the config". Each record names the operator (the API token that made the call), the action, the target, and the outcome — including refused actions, since a rejected ban is itself an auditable decision.

The trail is written from the API mutation handlers and exposed as a single read endpoint, GET /api/v1/audit. It is persisted to ClickHouse alongside attack and traffic history, and is tenant-scoped server-side just like every other read.

If you are not using multi-tenancy (a single deployment serving one customer or your own network), the tenant field is empty and an admin token sees every record — you can ignore the tenant-scoping notes below.

iAlways logged, queryable when storage is on

Every mutation is logged at INFO level regardless of storage. The queryable history needs ClickHouse: with storage disabled the endpoint returns available: false (it never errors), and you still have the audit lines in the process log.

What is recorded

Six actions are audited. ban, config_reload and source_block are recorded on both success and failure; edge_challenge on every 200 — clearing a lever that was not live still records cleared, only a refused lever (404, 400, 409) is left out; unban and source_unblock are recorded only on success — a failed or no-op removal returns an error and is not written to the trail, though it is still logged (a failed unban is a 404 "no active ban", not a policy decision, so there is nothing to attribute):

ActionWhenRecorded outcomes (result)
banPOST /api/v1/banactive on success; rejected when a safety guard refuses it. A dry-run ban still records active — the simulation is signalled by the separate dry_run field.
unbanPOST /api/v1/unbanwithdrawn on success.
config_reloadPOST /api/v1/config/reloadok on success; error when the new config fails to parse or validate.
source_blockPOST /api/v1/dataplane/sourcesblocked on success; rejected when policy refuses it. A refused block is itself an operator action, so it is attributed like a refused ban. The fingerprint plane also writes source_block records — source: "auto", success only — when it blocks a JA4.
source_unblockPOST /api/v1/dataplane/sources/unblockremoved on success.
edge_challengePOST/DELETE /api/v1/edge/zones/{name}/challengeset when the lever is pulled, cleared when it is released (DELETE or mode: off, whether or not a lever was live); reason is <mode> until <RFC 3339>; <operator's reason> on set, the operator's reason (mode: off) or empty (DELETE) on cleared. A refused lever — an unknown or out-of-tenant zone, a bad TTL, a zone in mode: none — is not written.

config_reload is restricted to unscoped (admin) tokens. A tenant-scoped operator is refused with 403 before the reload runs, and no audit record is written — that refusal appears only in the process log. So if a scoped reload seems to have vanished from the trail, that is why.

A refused action is audited deliberately: a ban rejected by the whitelist, the configured networks, or a blast-radius guard leaves an audit record with result: "rejected" and the refusal in reason. The audit trail therefore shows attempted as well as applied changes.

Operator identity

The operator field is the name of the matched API token — the name you give each entry in the api.tokens list. That is how the trail attributes an action to a person or system:

api:
  tokens:
    - { name: alice,      token_env: KAPKAN_ALICE, role: operator }
    - { name: automation, token_env: KAPKAN_AUTO,  role: operator }

A ban issued with the alice token records operator: "alice". On an open (token-less) API there is no principal to attribute, so operator is empty — another reason to set tokens before exposing the listener.

GET /api/v1/audit

Returns audit records newest-first. It needs the viewer role, like the other reads.

Query paramDefaultNotes
fromnow − 1hRFC 3339 start of the window.
tonowRFC 3339 end of the window. Must be after from; the window may not exceed 31 days.
action(all)Filter to one of ban, unban, config_reload, source_block, source_unblock, edge_challenge.
target(all)Filter to one IP. Must be visible to the caller's tenant, or the request is 403.

The endpoint returns at most ~1000 records per query, newest-first. If you hit that cap on a busy window, older events are silently dropped from the response — narrow the window or use the target/action filters.

GET /api/v1/audit?from=2026-06-22T00:00:00Z&action=ban HTTP/1.1
Authorization: Bearer <token>
{
  "available": true,
  "events": [
    {
      "event_time": "2026-06-22 03:14:09",
      "action": "ban",
      "result": "rejected",
      "operator": "alice",
      "role": "operator",
      "tenant": "customerA",
      "target": "203.0.113.66",
      "target_type": "host",
      "reason": "whitelisted",
      "source": "api",
      "ban_state": "rejected",
      "dry_run": 0
    }
  ]
}

Each record carries these fields:

FieldNotes
event_timeUTC, YYYY-MM-DD HH:MM:SS.
actionban, unban, config_reload, source_block, source_unblock, or edge_challenge.
resultactive, rejected, withdrawn, ok, error, blocked, removed, set, or cleared.
operatorThe matched API token name; empty in open (token-less) mode.
roleThe caller's role (viewer / operator).
tenantThe caller's tenant scope; empty for an unscoped (admin) caller.
targetThe IP for ban/unban; source->victim for source_block/source_unblock; the zone for edge_challenge; empty for config_reload.
target_typehost for ban/unban; source for source_block/source_unblock; zone for edge_challenge; global for config_reload.
reasonRefusal or error detail; empty on success — except for edge_challenge: on set the mode, its end and the operator's reason, on cleared the operator's reason or empty.
sourceapi for an operator action, or auto for an engine-initiated one. Today the fingerprint plane writes source_block records with source: "auto" — and no operator, role or tenant — when a client's JA4 is on the blocklist.
ban_stateThe final ban state for ban/unban; empty for config_reload, the source-block actions and edge_challenge.
dry_run1 when the action ran under dry-run (ban/unban, the source-block actions); always 0 for config_reload and edge_challenge, which dry-run does not gate.

A query against an out-of-range window returns 400; an unknown action value returns 400; a malformed target returns 400. With storage disabled the response is {"available": false, "events": []} at 200. events is always an array: a window that holds no record the caller may see — a tenant's first day, a filter that matches nothing — is [], never null.

Tenant scoping

The audit endpoint is tenant-scoped server-side, exactly like /attacks and /bans. A tenant-scoped token sees only the records whose tenant matches its own scope; the scope is bound during authentication and cannot be widened by any query parameter. An unscoped (admin) token sees every tenant's records. A target filter that names an address outside the caller's tenant is refused with 403, so the audit log is not a cross-tenant existence oracle.

Retention

Audit records live in the audit_events ClickHouse table and inherit the same ttl_days per-row TTL as the rest of storage — retention is bounded without operator intervention. Persistence is best-effort off the same bounded queue: a ClickHouse that cannot take rows loses them (see Backpressure) rather than blocking a ban, while the INFO log line is always emitted.