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):
| Action | When | Recorded outcomes (result) |
|---|---|---|
ban | POST /api/v1/ban | active 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. |
unban | POST /api/v1/unban | withdrawn on success. |
config_reload | POST /api/v1/config/reload | ok on success; error when the new config fails to parse or validate. |
source_block | POST /api/v1/dataplane/sources | blocked 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_unblock | POST /api/v1/dataplane/sources/unblock | removed on success. |
edge_challenge | POST/DELETE /api/v1/edge/zones/{name}/challenge | set 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 param | Default | Notes |
|---|---|---|
from | now − 1h | RFC 3339 start of the window. |
to | now | RFC 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:
| Field | Notes |
|---|---|
event_time | UTC, YYYY-MM-DD HH:MM:SS. |
action | ban, unban, config_reload, source_block, source_unblock, or edge_challenge. |
result | active, rejected, withdrawn, ok, error, blocked, removed, set, or cleared. |
operator | The matched API token name; empty in open (token-less) mode. |
role | The caller's role (viewer / operator). |
tenant | The caller's tenant scope; empty for an unscoped (admin) caller. |
target | The IP for ban/unban; source->victim for source_block/source_unblock; the zone for edge_challenge; empty for config_reload. |
target_type | host for ban/unban; source for source_block/source_unblock; zone for edge_challenge; global for config_reload. |
reason | Refusal 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. |
source | api 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_state | The final ban state for ban/unban; empty for config_reload, the source-block actions and edge_challenge. |
dry_run | 1 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.
Related
- REST API — the mutation endpoints whose actions are audited.
- Authentication — token names become the
operatoridentity. - Multi-tenancy — how the trail is scoped per tenant.
- Storage (ClickHouse) — where audit records are persisted.
- Safety model — the guards that produce
rejectedrecords.