Security
HMAC-signed hash chains, append-only guarantees, Postgres RLS tenant isolation, least-privilege permissions with a 2FA export gate, and impersonation capture.
Security
Signed, tamper-evident chains
Every record is content-hash-linked to the previous one and HMAC-signed with an off-host key (->hmacKeyFromSecret('VORTOS_AUDIT_HMAC_KEY'), value only in the sealed environment). AuditChainVerifier walks a chain and reports the first break — a mutated row, a gap, or a forged signature — with the exact sequence. This is cryptographic tamper-evidence, not DB triggers: it holds even against someone with direct table access, as long as they don't have the signing key.
With no HMAC key the chain is content-hashed but unsigned — detectable against casual edits, but an attacker who can recompute hashes could re-chain. vortos:audit:doctor warns until a key is set. Always set one in production.
Append-only
No code path issues UPDATE/DELETE on audit_events except the retention sweeper's archive-then-purge, which drops a contiguous, already-archived prefix and advances a checkpoint so the remainder still verifies. No API can edit history.
Tenant isolation (defense in depth)
Two independent layers:
- Query layer —
.ownendpoints forcetenantIdfromTenantContext; a tenant can never widen scope. - Postgres RLS — a DB-enforced backstop.
vortos:audit:pg:installenables + forces row-level security with a policy keyed on theapp.current_tenantGUC.AuditTenantRlsMiddlewaresets that GUC per request fromTenantContext(cleared for platform/system requests), rewritten each request so a pooled or worker-mode connection never inherits a previous request's scope. The policy restricts only when the GUC is set, so the async write path and platform reads stay unrestricted.
Because the policy hides platform-scoped rows (tenant_id IS NULL) from a tenant-scoped session, an endpoint that returns the caller's own cross-scope data — e.g. their platform-scoped auth events — must clear the GUC for that query (SELECT set_config('app.current_tenant', '', false)). The actor/owner filter already confines the result to the caller, so there's no leak.
Least privilege + 2FA on export
audit.read / audit.export / audit.verify / audit.admin are split across .own and .any. .any and verify are platform-role only. Export is data-exfiltration-sensitive, so the export endpoints carry #[Requires2FA] — a caller without a 2FA-verified (step-up) session is blocked. No wildcard grants.
No secrets in the trail
The HMAC key, DB creds, and DSNs are referenced by name only. The context blob must never carry secrets or full entity dumps — keep it a small structured detail object. AuditSource (ip / user-agent / session / request-id / device) is fine.
Impersonation captured
Any on-behalf-of / support session records the full onBehalfOf actor chain on the AuditActor, surfaced recursively in the console as an "impersonations" lens.
Export integrity
Exports carry a signed manifest (record count, time range, content SHA-256, HMAC). Verifying the manifest proves the body wasn't altered; tampering the NDJSON invalidates it.
Querying
Keyset query with action-prefix + free-text search + facet counts, saved views, and the vortos-audit-admin HTTP console API.
Retention
The archive-then-purge sweep — cold-archive an aged, already-verified prefix to object storage, advance a checkpoint, then drop it, so the remainder still verifies.