Audit Log
Every operator request emits one structured allow/deny event. Persisted to SQLite since v0.8.
Every /v1/* request emits exactly one structured tracing event:
event = "operator.allow"(INFO) on success.event = "operator.deny"(WARN) on RBAC denial.
Each entry carries actor, method, path, outcome, and (on deny)
the RbacError::code() reason. Raw bearer tokens never reach the
audit code path — the audit emitter only sees the post-verify
OperatorIdentity.
Persistence
Since v0.8 the audit log is durable in the SQLite audit table.
Earlier versions used an in-memory ring buffer (capacity 1000) — the
SPA's audit page has used the new envelope endpoint since then.
Reading the log
Live tail (v0.7-shape, back-compat)
curl -H "Authorization: Bearer $PORTUNUS_OPERATOR_TOKEN" \
'http://127.0.0.1:7080/v1/audit?limit=100&outcome=deny'
# → JSON array of AuditEntry objects, newest-firstThis path is byte-stable for v0.7 callers (no since/until/cursor).
Historic scroll-back (v0.8+ envelope)
# Last 24 hours, paginated
curl -H "Authorization: Bearer $PORTUNUS_OPERATOR_TOKEN" \
'http://127.0.0.1:7080/v1/audit?since=2026-05-07T15:00:00Z&limit=50'
# → {"entries":[...], "next_cursor":"abc123", "count":50}
curl -H "Authorization: Bearer $PORTUNUS_OPERATOR_TOKEN" \
'http://127.0.0.1:7080/v1/audit?cursor=abc123&limit=50'
# → next pageThe Web UI's audit page uses the envelope automatically — operator clicks "Load earlier" to walk the cursor.
Filter by outcome
?outcome=allow
?outcome=denyThe Web UI also exposes a client-side outcome filter for fast scrolling over already-fetched rows.
Pruning
The durable audit table is bounded by the prune CLI, not by a fixed ring size:
# Dry run
portunus-server audit prune --before 2026-05-01T00:00:00Z --dry-run
# Real prune (BEGIN IMMEDIATE + PRAGMA incremental_vacuum)
portunus-server audit prune --before 2026-05-01T00:00:00ZRun from cron daily / weekly to bound database growth.
Buffer overflow signal
portunus_audit_buffer_drops_total increments whenever an audit entry
is lost to backpressure: an eviction from the in-memory ring (at
capacity 1000) or an overflow of the durable writer's bounded hand-off
queue. Both are semantically "we lost an audit entry" and share the one
counter. It should stay at zero in normal operation; sustained growth
means the durable writer can't keep up. portunus_audit_durable_writer_lag_seconds
is the leading indicator — it climbs before drops start.
What is not audited
The audit log captures operator events only — pushing rules, managing users, etc. Data-plane events (forwarded byte counts, rate-limit rejects, SNI route decisions, DNS failures) are tracing-only and do not enter the audit ring or the SQLite audit table. This is a deliberate constitutional invariant (D13) so:
- The audit table size scales with operator activity, not traffic.
- Data-plane noise doesn't drown out RBAC events.
- Per-tenant ownership remains the audit's organising principle.
Export to JSON
The Web UI's "Download as JSON" button on the audit page emits an NDJSON file of currently-visible rows:
audit-2026-05-09.ndjson
{"timestamp":"…","actor":"alice","method":"POST","path":"/v1/rules","outcome":"deny","reason":"port_outside_grant"}
{"timestamp":"…",…}head -1 of the file is a complete AuditEntry JSON object — feed it
to your SIEM or a downstream pipeline.