Audit Logging
Mezite records a structured audit event for every security-relevant action in the cluster: logins, certificate issuance, SSH session lifecycle, access requests, and more. Events are written to the configured backing database (PostgreSQL or SQLite) via the audit Emitter. This guide covers event types, event structure, querying, the Emitter architecture, and external sinks.
Event Types
Every action in Mezite generates a typed audit event. The following table lists the core event types:
| Event Type | Description |
|---|---|
user.login | User authenticated (local password, SSO, or certificate) |
user.login.failed | Failed authentication attempt |
authz.denied | Authenticated user refused a webapi action by an RBAC gate (e.g.
non-admin POSTing to /v1/webapi/users) |
user.cert.issued | User certificate issued by the CA |
session.start | SSH session started |
session.end | SSH session ended (records duration; recording metadata when the session was recorded) |
node.joined | Agent registered with the cluster |
node.left | Agent disconnected or was removed |
user.created | User account created |
access_request.created | Access request submitted |
access_request.approved | Access request approved |
access_request.denied | Access request denied |
access_request.expired | Access request or granted access expired |
Event Structure
Each audit event is a structured JSON document with a consistent schema. All events share common fields, with type-specific fields nested under the relevant key.
{
"id": "f3a1...uuid",
"event_type": "session.start",
"event_code": "T2000I",
"user_name": "alice",
"resource_type": "node",
"resource_name": "web-server-01",
"server_hostname": "web-server-01",
"client_ip": "203.0.113.10",
"timestamp": "2026-03-24T10:16:01.234Z",
"session_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"success": true
}
The full AuditEvent schema (defined in server/audit/events.go) also includes cluster_name, user_roles,
resource_labels, server_id, impersonator, error_message, and a free-form details map. Most
fields use omitempty, so events only carry the columns the
emitter explicitly populates. The exact set varies by event type —
inspect server/proxy, server/auth, and server/web emit sites to see which fields are set for each event.
{
"id": "9b2c...uuid",
"event_type": "user.login",
"event_code": "T1000I",
"user_name": "alice",
"client_ip": "203.0.113.10",
"timestamp": "2026-03-24T10:15:32.567Z",
"success": true
}
Today the user.login event does not record which authentication
method (password, SSO connector, certificate) was used — clients distinguish
that by the surrounding flow, not by a field on the event itself.
Querying Audit Events
Use mezctl audit ls to query the audit log with flexible filtering.
# List events from the last hour (default --since=1h)
mezctl audit ls
# Show events from the last 24 hours
mezctl audit ls --since=24h
# Filter by event type
mezctl audit ls --type=user.login --since=24h
# Filter by user
mezctl audit ls --user=alice --since=7d
# Combine filters
mezctl audit ls --type=session.start --user=alice --since=1h Example output:
TIME TYPE USER RESOURCE CLIENT_IP STATUS
2026-03-24 10:15:32 user.login alice 203.0.113.10 ok
2026-03-24 10:15:45 user.cert.issued alice 203.0.113.10 ok
2026-03-24 10:16:01 session.start alice node/web-server-01 203.0.113.10 ok
2026-03-24 10:28:35 session.end alice node/web-server-01 203.0.113.10 ok The Emitter Architecture
Mezite's audit system uses a producer/consumer split between event emit sites and the database writer, so audit writes do not block the critical path of SSH session establishment.
Buffered Async Writes
Most audit events are written asynchronously via a buffered channel. The Emitter accepts events from any goroutine, queues them in a channel buffer, and a background writer flushes them to the configured database in batches.
goroutine A (session.start) ──┐
goroutine B (session.command) ──┼──> [buffered channel] ──> background writer ──> database
goroutine C (node.joined) ──┘
capacity: 4096 events
batch size: up to 100 events
flush ticker: 500ms This design means that SSH session establishment is not blocked by database write latency. When the buffer is full, additional async events are dropped with a warning log line — so this path is reserved for high-volume informational events. Security-relevant events use the sync path below.
Sync Writes for Critical Events
Certain high-priority events bypass the buffer and are written synchronously to guarantee they are persisted before the operation completes:
-
Authentication outcomes:
user.login,user.login.failed,user.cert.issued -
Identity mutations:
user.created,user.totp_reset,user.webauthn_reset, lock and connector CRUD -
Every denial event (any
*.denied/*.failed) -
CA lifecycle:
ca.cert.issued,ca.rotate.*
Sync writes ensure that if a sensitive change is acknowledged to the
admin, it is already recorded in the audit log. See
server/audit/CLAUDE.md for the full classification.
External Sinks
In addition to the database, audit events can be mirrored to external sinks for SIEM integration, alerting, or long-term archival. Two sink types are supported today: webhook (POSTs each event as JSON to a URL) and file (appends to a JSONL file on disk). Sinks receive every event after it has been written to the database — there is no per-sink event-type filter yet, so filter downstream if you only want a subset. Treat sinks as best-effort observability fanout, not as the source of truth: the database write is always authoritative.
Enable a webhook sink
# POSTs each audit event as a JSON body to the configured URL
export MEZITE_AUDIT_SINK_WEBHOOK_URL=https://siem.example.com/mezite/events
# Optional — HTTP timeout for the POST (default 5s)
export MEZITE_AUDIT_SINK_WEBHOOK_TIMEOUT=10s
mezhub --config=mezite.yaml Enable a file sink
# Append each audit event as a single JSON object per line
export MEZITE_AUDIT_SINK_FILE_PATH=/var/log/mezite/audit.jsonl
mezhub --config=mezite.yaml
# Ship to your log pipeline (Vector, Fluent Bit, Filebeat, etc.) using
# the standard JSONL pattern.
tail -F /var/log/mezite/audit.jsonl | vector --config vector.toml Worked example: Splunk HEC
Splunk HTTP Event Collector accepts a flat JSON event under an
event key. A thin wrapper (Lambda, single-page
Cloudflare Worker, or a one-line nginx location with
js_content) reshapes Mezite's body into Splunk's HEC
envelope:
export default {
async fetch(req, env) {
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
const event = await req.json();
const body = JSON.stringify({
sourcetype: "mezite:audit",
source: "mezite-prod",
time: Math.floor(new Date(event.timestamp).getTime() / 1000),
event,
});
return fetch("https://splunk.example.com:8088/services/collector/event", {
method: "POST",
headers: {
Authorization: "Splunk " + env.SPLUNK_HEC_TOKEN,
"Content-Type": "application/json",
},
body,
});
},
}; Datadog Logs, Elastic Ingest, and most other SIEMs follow the same shape: stand up an HTTP receiver that maps the Mezite payload into the SIEM-native envelope and forwards. If your SIEM accepts JSONL files directly, use the file sink and let your log forwarder do the delivery — that is the lower-friction path for most installations.
Event payload shape
Every sink delivery is the JSON serialisation of a single audit event. The fields you can rely on are stable across releases:
| Field | Type | Description |
|---|---|---|
event_type | string | Dotted event name (session.start, access.denied.port_forwarding, ...). |
event_code | string | Short opaque code for monitoring / alerting (T4001W, T7006W, ...). |
timestamp | RFC3339 | When the event was emitted by mezhub. |
user_name | string | Authenticated user the action was attributed to (empty for pre-auth events like login failures). |
client_ip | string | Source IP if applicable. |
session_id | string | Set on session-scoped events. Joins to the recordings table. |
node_name | string | Target node for session-scoped events. |
login | string | Requested OS login for SSH events. |
success | bool | Outcome flag. false for denial / failure events. |
error_message | string | Human-readable reason on failures; not present on successes. |
metadata | object | Event-type-specific key/value extras (e.g. requested_roles on access-request events). |
Additional fields can appear on specific event types; treat the
payload as a forward-compatible JSON object and only key off the
fields above for hard logic. The full classification (which events
are sync-emitted and never dropped, vs. async-buffered) lives in
server/audit/CLAUDE.md.
Storage and Retention
Audit events are stored in the configured database (PostgreSQL or
SQLite). There is no automatic retention or purge mechanism — events
accumulate until you prune them out of band (for example, with a
scheduled SQL delete against audit_events).
An S3-compatible archiver exists in server/audit/s3_archiver.go and a mezctl audit archive CLI is wired up, but the server-side
ArchiveAuditEvents RPC currently returns
Unimplemented. Plan around external retention until the
server handler ships.
Troubleshooting
Audit events missing
- Verify the auth service is connected to its database and migrations are up to date.
-
Check
mezhublogs foraudit buffer full, dropping eventwarnings — high-volume informational events use a 4096-event channel and are dropped when the buffer is saturated. Sync events (logins, denials, identity mutations) are never dropped.
Slow audit queries
- Use filters (type, user, time range) to narrow the query scope.
-
For large deployments, ensure the database has appropriate indexes
(applied by migrations under
server/migrate/migrations/).
Next Steps
- Session Recording — Learn about SSH session recording and playback.
- RBAC Configuration — Control who can view audit logs.
- SSH Access — Understand the events generated by SSH sessions.
- Access Requests — Audit trail for approval workflows.