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

Three actions are audited. ban and config_reload are recorded on both success and failure; unban is recorded only on success — a failed or no-op unban 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.

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.
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, or config_reload.
resultactive, rejected, withdrawn, ok, or error.
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; empty for config_reload.
target_typehost for ban/unban; global for config_reload.
reasonRefusal or error detail; empty on success.
sourceapi (operator action). Engine-internal automatic actions are reserved for a future auto source.
ban_stateThe final ban state for ban/unban; empty for config_reload.
dry_run1 when the deployment is in dry-run.

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.

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 slow or down ClickHouse drops rows rather than blocking a ban, while the INFO log line is always emitted.