Audit Event Log
PostMTA maintains an immutable, append-only audit log for every security-relevant action that occurs across the platform. The log captures 25+ event types spanning send operations, authentication events, configuration changes, and administrative actions. All entries include operator identity, client IP address, and timestamp with nanosecond precision.
Append-Only Guarantee (PMH-SEC-012)
The audit log is designed to satisfy the immutable log requirement defined in PMH-SEC-012. Entries are written to a write-ahead log (WAL) segment that is replicated across three availability zones before acknowledgement. Once written, entries can never be modified or deleted — not even by a platform administrator with full database privileges.
Immutability is enforced at the storage layer:
- WAL segments are stored in an append-only S3 bucket with object lock (S3 Object Lock compliance mode).
- No DELETE or UPDATE statements are permitted on the
audit_eventstable via any database role, including superuser. - A cryptographic message authentication code (CMAC) is appended to each log batch to enable tamper detection.
- Quarterly integrity checks recompute CMAC over historical batches and flag any mismatch.
Event Types
The following table lists all currently instrumented event types. Each entry shows the trigger condition and the metadata fields included in the metadata JSON column.
+-------------------+----------------------------------------+------------------------------------+
| Event Type | Trigger | Metadata Fields |
+-------------------+----------------------------------------+------------------------------------+
| send | Message submitted to transport | msg_id, subuser_id, from, to, |
| | | ip_pool_id, template_id |
| domain_verify | DNS verification check completed | domain, verification_method, |
| | | record_type, record_value |
| api_key_create | New API key generated | key_id, key_name, scopes[], |
| | | created_by, expires_at |
| api_key_rotate | API key secret rotated | key_id, rotated_by |
| api_key_revoke | API key revoked | key_id, revoked_by |
| webhook_create | Webhook endpoint registered | webhook_id, url, events[], |
| | | created_by |
| webhook_delete | Webhook endpoint removed | webhook_id, deleted_by |
| shaping_apply | Shaping policy applied to subuser | subuser_id, policy, applied_by |
| shaping_override | Manual epoch override issued | subuser_id, from_epoch, to_epoch, |
| | | reason, applied_by |
| fbl_complaint | ARF complaint processed | recipient, source_isp, |
| | | feedback_type, complaint_id |
| fbl_fraud | ARF fraud/phishing report received | recipient, source_isp, complaint_id |
| suppress_add | Address added to suppression list | recipient, subuser_id, reason, |
| | | ttl_days, added_by |
| suppress_remove | Address removed from suppression list | recipient, removed_by |
| subuser_create | Subuser account created | subuser_id, plan, created_by |
| subuser_suspend | Subuser account suspended | subuser_id, reason, suspended_by |
| subuser_delete | Subuser account deleted | subuser_id, deleted_by |
| billing_alert | Credit threshold crossed | subuser_id, credits_remaining, |
| | | threshold_type |
| mfa_enabled | MFA enabled on account | user_id, mfa_method (totp/webauthn)|
| mfa_disabled | MFA disabled on account | user_id, disabled_by |
| export_request | Data export initiated | export_id, export_type, |
| | | requested_by, record_count |
| login_success | Successful authentication | user_id, ip_address, user_agent, |
| | | auth_method |
| login_failure | Failed authentication attempt | attempted_user, ip_address, |
| | | reason, user_agent |
| permission_denied | Action blocked by RBAC | user_id, action, resource, |
| | | required_scope |
+-------------------+----------------------------------------+------------------------------------+Schema
The audit_events table schema:
| Column | Type | Description |
|---|---|---|
event_id | UUID | Unique event identifier (ksuid-based) |
event_type | varchar(64) | Event type name (see table above) |
occurred_at | timestamptz | Wall-clock time of the event (UTC, nanosecond precision) |
actor_id | UUID | User ID of the principal that triggered the event. NULL for system-generated events. |
actor_type | enum | One of: user, subuser, system, api_key, webhook |
ip_address | inet | Client IP address (IPv4 or IPv6). NULL for internal system events. |
user_agent | varchar(512) | Client User-Agent string for API and web events |
resource_type | varchar(64) | Type of resource affected (message, subuser, api_key, domain, etc.) |
resource_id | varchar(128) | ID of the affected resource |
metadata | jsonb | Event-specific key-value pairs (schema varies by event_type) |
cmac_hash | text | CMAC-SHA256 of the log batch segment (for tamper evidence) |
Actor Resolution via SQL
The actor_id in audit events is a raw UUID. To resolve it to a human-readable identity for investigation, join against the users and subusers tables:
SELECT
a.event_id,
a.event_type,
a.occurred_at,
a.metadata,
u.user_id,
u.email AS actor_email,
u.full_name AS actor_name,
COALESCE(s.subuser_id, a.metadata->>'subuser_id') AS affected_subuser
FROM audit_events a
LEFT JOIN users u ON a.actor_id = u.user_id
LEFT JOIN subusers s
ON s.subuser_id = a.metadata->>'subuser_id'
WHERE a.event_type IN ('send', 'shaping_override', 'suppress_add',
'api_key_create', 'mfa_enabled')
AND a.occurred_at >= NOW() - INTERVAL '30 days'
ORDER BY a.occurred_at DESC
LIMIT 100;IP Logging
Client IP addresses are captured for all events initiated by a human actor or an API key. The following rules apply:
- Web UI events — IP extracted from the TLS termination layer (real client IP, not proxy hop).
- API events — IP extracted from
X-Forwarded-Forheader, validated against the API gateway's trust proxy list. - Internal system events — IP is recorded as
NULL(e.g., automated epoch promotion, scheduled suppression TTL expiry). - Webhook-triggered events — IP of the PostMTA internal worker that processed the webhook delivery.
inet column as-is. Queries using inet_not_in() or inet_contains() operate correctly on both IPv4 and IPv6 values.Retention Policy
| Storage Tier | Duration | Queryable via API | Cost |
|---|---|---|---|
| Hot (PostgreSQL / read replica) | 0 – 90 days | Yes, < 5 s response | Standard |
| Warm (S3 Standard) | 90 days – 2 years | Yes, via export API, up to 24 h latency | Reduced |
| Cold (S3 Glacier) | 2 years – 7 years | Yes, via export API, up to 12 h retrieval | Minimal |
| Archived | > 7 years | Manual request only | Compliance tier |
Compliance-tier archives are accessible only via a written request approved by the Data Protection Officer (DPO) and require a 30-day notice period. Archive retrieval is logged in the audit log itself (event type archive_retrieval).
Admin Export API
Account administrators can export audit events via the REST API. Exports are returned as gzip-compressed NDJSON and are available for download for 24 hours after generation. The export API supports filtering by event type, date range, subuser, and actor.
POST /v1/audit/export
Content-Type: application/json
Authorization: Bearer pmta_live_...
{
"start_time": "2026-06-01T00:00:00Z",
"end_time": "2026-07-18T23:59:59Z",
"event_types": [
"send", "shaping_override", "api_key_create",
"fbl_complaint", "mfa_enabled", "permission_denied"
],
"subuser_id": "sub_usr_01HX...",
"format": "ndjson",
"compression": "gzip"
}
-- Response:
{
"export_id": "aud_exp_01HX9P7N3...",
"status": "processing",
"record_count": null,
"download_url": null,
"expires_at": "2026-07-19T12:00:00Z"
}
-- Poll for completion:
GET /v1/audit/export/aud_exp_01HX9P7N3...
Authorization: Bearer pmta_live_...
{
"export_id": "aud_exp_01HX9P7N3...",
"status": "ready",
"record_count": 48291,
"download_url": "https://exports.postmta.com/aud_exp_01HX...gz",
"expires_at": "2026-07-19T12:00:00Z",
"checksum_sha256": "a3f5c8..."
}audit:export scope is required. This scope is granted only to roles with admin or security_admin. Export requests are themselves logged in the audit log (event type export_request).Searching the Audit Log
Filter by Event Type
GET /v1/audit/events?event_type=shaping_override&start=2026-07-01T00:00:00Z
Authorization: Bearer pmta_live_...Filter by Resource
GET /v1/audit/events?resource_type=subuser&resource_id=sub_usr_01HX...
Authorization: Bearer pmta_live_...Filter by Actor
GET /v1/audit/events?actor_id=usr_01HX9P7N3...&limit=50
Authorization: Bearer pmta_live_...Technical Immutability Controls
Beyond the storage-layer controls described above, PostMTA enforces immutability through several additional mechanisms:
- DB role restrictions: The
audit_eventstable is owned by a role (audit_writer) that has INSERT-only privileges. No application role — including superuser — has UPDATE or DELETE on this table. - Audit writer service: A dedicated microservice (
pmh-audit-writer) is the only process authorised to INSERT into audit_events. It has no network permissions to any other database tables, limiting blast radius. - CMAC chain: Each log batch (group of up to 500 events) is sealed with a CMAC-SHA256 key derived from a Hardware Security Module (HSM). The HMAC key never leaves the HSM.
- Quarterly integrity reports: A scheduled job recomputes the CMAC over historical batches and compares against stored values. Any mismatch triggers a PagerDuty alert and halts new audit writes pending investigation.
Partitioning Strategy
The audit_events table is partitioned by month using PostgreSQL declarative range partitioning. Old partitions are detached and archived to S3 without affecting query performance on recent data. Queries spanning multiple months are automatically routed to the relevant partitions.
-- Example: list active partitions
SELECT
relname AS partition_name,
pg_size_pretty(pg_relation_size(relid)) AS size
FROM pg_stat_user_tables
WHERE relname LIKE 'audit_events_%'
ORDER BY relname;Cross-Account Audit Access
For enterprise accounts with multiple subaccounts (multi-tenant setups), the audit log supports cross-account queries for users with the security_admin role. Cross-account queries use the all_subaccounts=true filter:
GET /v1/audit/events?all_subaccounts=true&event_type=permission_denied&start=2026-07-01T00:00:00Z
Authorization: Bearer pmta_live_...
{
"events": [...],
"meta": {
"total_matched": 142,
"subaccounts_queried": ["sub_usr_01HX...", "sub_usr_02HY..."],
"query_time_ms": 387
}
}security_admin role holders may query across subaccounts. Standard admin and operator roles are limited to their own subaccount's events.Security Alert Events
The following event types should trigger immediate security review when observed in the audit log:
permission_denied— A user or API key attempted an action without the required scope. May indicate credential misuse.login_failure— Repeated failures (≥ 5 in 10 minutes) for the same user may indicate a brute-force attempt.mfa_disabled— MFA was disabled. Correlate withpermission_deniedevents from the same IP in the preceding 24 hours.fbl_fraud— A phishing/spoofing complaint was received. Immediate investigation required (PMH-SEC-034).api_key_revoke— An API key was revoked. Verify this was intentional and not the result of a compromise.
Audit Dashboard
The Reporting → Audit Log panel in the PostMTA dashboard provides a searchable, filterable UI for the last 90 days of audit events. Operators can:
- Search by keyword across all metadata fields.
- Filter by event type, date range, subuser, and actor.
- Export filtered results as CSV or NDJSON.
- Set up saved searches with email or Slack alert hooks.