Audit Logging
Structured JSON audit trail for every security-relevant event in Portwing, with guidance for immutable export.
Portwing writes a structured JSON audit log — one event per line — that records every security-relevant action: authentication attempts, key enrollments, Compose operations, exec tunnel starts, and rate-limit blocks. The audit stream is intentionally separate from the operational log so records can be routed to a dedicated sink without mingling with debug noise.
The file/stdout sink schema and the pull-export schema are stable from v0.8.0. New optional fields may be added in a minor release; existing field names, meanings, and event identifiers follow the deprecation process in the Stability Policy.
Enabling audit logging
Set the AUDIT_LOG environment variable when starting the container. The
persistent file/stdout/stderr sink is disabled by default, but the in-memory
audit ring remains enabled with its default AUDIT_BUFFER_SIZE=256. Set
AUDIT_BUFFER_SIZE=0 if you also want to disable in-memory retention and pull
export.
# Append to a dedicated file (opened append-only, mode 0600)
docker run -e AUDIT_LOG=/var/log/portwing-audit.log \
ghcr.io/codeswhat/portwing:latest
# Write to stdout — useful when a log aggregator already captures container stdout
docker run -e AUDIT_LOG=stdout \
ghcr.io/codeswhat/portwing:latest
# Write to stderr
docker run -e AUDIT_LOG=stderr \
ghcr.io/codeswhat/portwing:latestFile sinks are opened with mode 0600 (owner read/write only). On Linux you can bind-mount a host directory and use a tool like logrotate or forward with filebeat/fluentd without any additional configuration on Portwing's side.
See Configuration for the full environment variable reference.
Output format
Every audit record is a JSON object on a single line terminated by a newline (\n). The time field uses RFC 3339 with nanosecond precision, always in UTC. Records are flushed synchronously — no buffering is introduced between an event occurring and it being written.
The msg field is always an empty string; all semantic content lives in the named fields described below.
Stable sink schema
Core fields (all events)
| Field | Type | Description |
|---|---|---|
time | string | RFC 3339 nanosecond timestamp in UTC |
level | string | Always "INFO" for audit records |
msg | string | Always "" — use event for the record type |
event | string | Event type identifier — see the table below |
actor | string | Client IP address or edge peer URL |
method | string | HTTP method or message type; only present on api_request, auth_failure, and rate_limited events |
path | string | HTTP path or message path; only present on api_request, auth_failure, and rate_limited events |
outcome | string | One of allowed, denied, or error |
status | integer | HTTP status code; omitted when not applicable |
duration_ms | float | Request duration in milliseconds; omitted when not measured |
Event types
event value | Triggered when |
|---|---|
api_request | Any authenticated HTTP API call completes |
auth_failure | An invalid or missing token is presented |
rate_limited | A request is blocked by the rate limiter |
compose_op | A Docker Compose lifecycle operation runs |
exec_start | An interactive exec tunnel opens |
enrollment | An Ed25519 key is enrolled via /api/portwing/enroll |
Additional fields by event type
compose_op — Compose lifecycle events add:
| Field | Type | Description |
|---|---|---|
operation | string | Compose subcommand: up, down, pull, etc. |
stack | string | Stack name as provided by the caller |
exec_start — Exec tunnel events add:
| Field | Type | Description |
|---|---|---|
container | string | Exec path or container ID |
exec_id | string | Exec resource ID assigned by the agent |
enrollment — Key enrollment events add:
| Field | Type | Description |
|---|---|---|
key_id | string | The fingerprint or identifier of the enrolled Ed25519 public key |
Sample events
Authenticated API request (success):
{"time":"2026-01-15T10:23:45.123456789Z","level":"INFO","msg":"","event":"api_request","actor":"203.0.113.42","method":"POST","path":"/_portwing/compose","outcome":"allowed","status":200,"duration_ms":3.14}Authentication failure:
{"time":"2026-01-15T10:23:50.001000000Z","level":"INFO","msg":"","event":"auth_failure","actor":"203.0.113.9","method":"GET","path":"/_portwing/info","outcome":"denied"}Rate-limited request:
{"time":"2026-01-15T10:23:51.500000000Z","level":"INFO","msg":"","event":"rate_limited","actor":"203.0.113.9","method":"POST","path":"/_portwing/exec","outcome":"denied"}Docker Compose operation:
{"time":"2026-01-15T10:23:45.200000000Z","level":"INFO","msg":"","event":"compose_op","actor":"203.0.113.42","operation":"up","stack":"nginx-stack","outcome":"allowed"}Exec tunnel opened:
{"time":"2026-01-15T10:24:01.500000000Z","level":"INFO","msg":"","event":"exec_start","actor":"203.0.113.42","container":"abc123def456","exec_id":"e7f8a9b1","outcome":"allowed"}Ed25519 key enrolled:
{"time":"2026-01-15T10:20:00.000000000Z","level":"INFO","msg":"","event":"enrollment","actor":"203.0.113.42","key_id":"3a7f9c1d2e4b5a6f","outcome":"allowed"}Shipping the log
Because audit records are plain newline-delimited JSON, they slot into any log pipeline that accepts structured text:
- Stdout/stderr sinks — pass
AUDIT_LOG=stdoutand let your container runtime (Docker, Kubernetes) collect the stream. Pointfluentd,Fluent Bit, or the AWS/GCP/Azure log drivers at it as normal. - File sink — bind-mount a host directory, write to a named file, and consume with
filebeat,promtail,vector, or any sidecar that tails files. - Immutable storage — for tamper-evidence, ship records to an append-only destination (S3 Object Lock, AWS CloudTrail Lake, or a WORM-capable SIEM) immediately; Portwing itself does not provide on-disk integrity protection beyond the
0600file mode.
The operational log (controlled by the LOG_LEVEL environment variable) is always written to stdout via slog. Setting AUDIT_LOG=stdout merges both streams into stdout, which is fine for log aggregators that parse JSON lines — each record's event field distinguishes audit entries from operational ones.
Auth and key-enrollment events as first-class records
auth_failure and enrollment events are emitted before any business logic runs, making them reliable indicators of credential probing and key-management activity regardless of request outcome. Every failed token presentation produces an auth_failure record with the actor IP, method, and path — no successful request is required to trigger logging.
This means audit coverage begins at the perimeter: you can alert on auth_failure spikes or unexpected enrollment events without parsing application logs. See Authentication for how Ed25519 keys are verified and Security Model for the broader threat model these events are designed to surface.
In-memory ring buffer and pull endpoint
Portwing maintains an in-memory ring buffer of the most recent audit records for pull-based retrieval. The buffer size is controlled by AUDIT_BUFFER_SIZE (default 256). It is independent of AUDIT_LOG — records are added to the ring buffer regardless of whether a file or stream sink is configured.
GET /_portwing/audit
Auth required. Returns the most recent records from the ring buffer, newest first.
| Query param | Default | Description |
|---|---|---|
limit | AUDIT_BUFFER_SIZE | Maximum number of records to return. |
Response shape:
{
"records": [
{"cursor":42,"ts":"2026-01-15T10:24:01.500000000Z","event":"exec_start","actor":"203.0.113.42","container":"abc123def456","exec_id":"e7f8a9b1","outcome":"allowed"},
{"cursor":41,"ts":"2026-01-15T10:23:50.001000000Z","event":"auth_failure","actor":"203.0.113.9","method":"GET","path":"/_portwing/info","outcome":"denied"}
],
"count": 2
}The ring-buffer schema is also stable, but intentionally differs from the sink
schema: it uses ts, adds a monotonic cursor, and omits the sink-only
level and msg fields. Cursors start at 1 for each agent process.
Set AUDIT_BUFFER_SIZE=0 to disable the in-memory buffer entirely. This does not affect the file or stream sink configured via AUDIT_LOG.
GET /_portwing/audit/export
This endpoint is designed for polling exporters and returns
application/x-ndjson in oldest-first order. Each line is one ring-buffer
record using the stable export schema. Standard mode requires authentication.
Edge mode exposes this route on its local operations listener without inbound
authentication.
The Edge operations listener is a private-operations trust boundary. Anyone
who can reach it can export retained audit data, including client addresses,
request paths, stack names, and container identifiers. Keep it on a dedicated
container/pod network and restrict it to your exporter or Prometheus pod. Do
not publish it through an ingress, NodePort, LoadBalancer, host port, or a
cluster-wide Service without an authenticating proxy in front.
For the first poll, omit cursor:
curl -sS -D audit-headers.txt \
-H "Authorization: Bearer $TOKEN" \
https://portwing.example.com/_portwing/audit/exportAfter consuming the entire response, persist X-Portwing-Next-Cursor and send
it as the next request's cursor. limit bounds the number of records returned;
zero or omission returns every retained match.
curl -sS -D audit-headers.txt \
-H "Authorization: Bearer $TOKEN" \
"https://portwing.example.com/_portwing/audit/export?cursor=42&limit=500"For Edge mode, make the same request from the private operations network and omit the Authorization header:
curl -sS -D audit-headers.txt \
"http://portwing-edge:3000/_portwing/audit/export?cursor=42&limit=500"| Response header | Meaning |
|---|---|
X-Portwing-Oldest-Cursor | Oldest record still retained in memory |
X-Portwing-Latest-Cursor | Newest cursor assigned by this process |
X-Portwing-Next-Cursor | Cursor to persist after consuming this response |
X-Portwing-Record-Count | Number of NDJSON records in the response |
A 409 Conflict means the supplied cursor is outside the current buffer
generation: retained records were overwritten, or the agent restarted and
cursor numbering began again. The response's X-Portwing-Next-Cursor gives the
safe reset point, but the exporter should surface the gap before resuming so
data loss is never silent.
The ready-to-run Compose and Kubernetes examples pair the file sink with a pinned Fluent Bit sidecar. Its stdout output works with the cluster/runtime collector immediately; replace the output block with your HTTP, OpenTelemetry, Kafka, object-storage, or SIEM destination.