7.0 KiB
Compliance-Audit-Export
Phase 10 Task #67. Tamper-evident, SOC2/HIPAA/GDPR-ready audit log with a self-service export UI for tenants and a system-wide export for platform admins.
Overview
AuditEvent (prisma/schema/core.prisma) is the single normative store
for privileged actions across the platform. Phase 10 extended the model
with:
| Field | Purpose |
|---|---|
actorEmail |
denormalized actor email for compliance reports |
actorIp |
first-hop client IP (cf-connecting-ip / x-real-ip / xff) |
actorUserAgent |
UA capped at 512 chars |
sourceModule |
auth / billing / admin / plugin:foo |
outcome |
success / failure / blocked |
dataSensitivity |
public / internal / confidential / pii |
regulatoryScope |
string[]: gdpr hipaa soc2 pci |
previousHash |
predecessor eventHash for the same tenant chain |
eventHash |
HMAC-SHA-256 over canonical payload + previousHash |
Existing fields (tenantId, actorId, actorType, action,
targetType, targetId, requestId, metadata) remain unchanged.
Hash-Chain
Per-tenant chain: each tenant's events form an independent linked-list
keyed by previousHash. Platform-level events (tenantId IS NULL) form
their own chain.
eventHash_n = HMAC-SHA-256(AUDIT_CHAIN_SECRET, eventHash_{n-1} || canonicalJSON(payload_n))
canonicalJSON is the JSON encoding produced by
canonicalizeAuditPayload in
packages/db/src/audit-hash.ts: object keys sorted recursively,
undefined dropped, arrays preserve order. Production fails closed unless
AUDIT_CHAIN_SECRET contains at least 32 UTF-8 bytes. Local development and
tests may use the explicit SHA-256 fallback, but it is never an accepted
production configuration. New v2: hashes bind the persisted event ID and
creation timestamp; the verifier retains dual compatibility with unversioned
legacy hashes. Concurrent writers serialize through a per-chain PostgreSQL
advisory transaction lock and an HMAC-authenticated O(1) chain-head row.
verifyAuditChain(events, { secret }) walks the chain oldest → newest
and returns the first break with one of:
previous-hash-mismatch— chain discontinuity (an event was inserted or deleted)hash-mismatch— payload was modified in-place
Auto-Capture
API (Hono)
apps/api/src/middleware/audit-context.ts registers an AsyncLocalStorage
binding on every request and exposes getAuditContext(). Deeper code
(createAuditEvent calls in services) automatically picks up actor IP,
user agent, request id without threading the request object.
import { createAuditContextMiddleware } from "./middleware/audit-context.js";
app.use("*", createAuditContextMiddleware());
Web (React-Router v7)
apps/web/app/utils/audit/withAuditContext.ts mirrors the API helper for
loaders/actions:
export const action = async (args: ActionFunctionArgs) =>
withAuditContext(args, async () => {
// ...createAuditEvent calls deeper in the stack pick up actor ctx
});
For ad-hoc reads extractWebActorIp(request) and
extractWebActorUserAgent(request) are exported too.
Export UI
Two routes ship:
/admin/audit-export— platform admin, system-wide. OptionaltenantIdfilter./app/$tenant/settings/audit-export— tenant self-service. tenantId is force-applied server-side; query-string spoofing is silently dropped.
Both stream a CSV up to 50 000 rows (AUDIT_EXPORT_MAX_ROWS). The
first line of the CSV is the verifyAuditChain integrity header so
the download itself is the auditor evidence package:
# verifyAuditChain: ok=true inspected=12345 firstBreakId=- reason=-
id,createdAt,tenantId,actorType,actorId,…
The HTTP response also carries an X-Audit-Chain-Status: ok|broken
header so a programmatic client can branch without parsing the CSV.
Filters:
from/to(ISO date, inclusive UTC range; max 1 year recommended)regulatoryScope(multi)actorType(user/admin/api_key/system/plugin)outcome(success/failure/blocked)tenantId(admin variant only)
CSV cells with commas, quotes or newlines are RFC-4180 escaped; arrays
join with |; JSON metadata is stringified.
Retention
apps/worker/src/jobs/audit-retention.ts exposes runAuditRetentionJob
which the worker host wires to a daily cron.
The retention window is fixed at ten calendar years for every tenant chain and the platform chain. Tenant discovery uses one aggregate query. Deletes run in bounded, keyset-ordered batches; before each batch is deleted, the same transaction writes an append-only HMAC-authenticated checkpoint that binds the last removed event hash and the previous checkpoint MAC. Verifiers and exports authenticate this checkpoint before accepting the retained suffix.
Every non-empty sweep emits a meta-audit (audit.retention.pruned) so
the cleanup itself is traceable.
Operator Procedures
Bootstrap
- Generate a secret:
openssl rand -base64 32. - Set
AUDIT_CHAIN_SECRET=<value>in Coolify (platform-wide, never per-tenant). - Apply the migration that creates
AuditChainHeadandAuditChainCheckpointbefore deploying the new writers.
Self-service export (tenant admin)
- Navigate to
Settings → Audit Export. - Pick date range + filters.
- Click Download CSV.
- Verify the first line shows
ok=true. Ifok=false, contact platform support — chain integrity has been compromised.
Forensic verify (platform admin)
# In a node REPL with the export CSV:
const { verifyAuditChain } = await import("@hivegrid/db/audit-hash");
// load events from CSV/DB, then:
verifyAuditChain(events, { secret: process.env.AUDIT_CHAIN_SECRET });
Tampering detection drill
psql ... -c "UPDATE \"AuditEvent\" SET metadata = '{\"tampered\":true}' WHERE id = '<id>'"
Re-export the tenant — the integrity header now reports
ok=false reason=hash-mismatch firstBreakId=<id>.
Roadmap
- Admin per-tenant retention-policy override JSON
- S3-glacier auto-archive before retention delete
- Pre-built SOC2 / GDPR / HIPAA report templates (PDF with signature hash)
- CLI:
pnpm audit:verify-chain --tenant <id>direct DB walker - Outbox-driven audit writes for transactional consistency with business writes (Phase 03 #42 integration)
Related
prisma/migrations/20260517180000_10_audit_event_compliance/packages/db/src/audit-hash.ts— chain helperspackages/db/src/audit-events.server.ts— single write-pathapps/api/src/middleware/audit-context.ts— API auto-captureapps/web/app/utils/audit/withAuditContext.ts— web auto-captureapps/web/app/routes/admin/audit-export.tsx— admin export UIapps/web/app/routes/app.$tenant/settings/audit-export.tsx— tenant export UIapps/worker/src/jobs/audit-retention.ts— retention worker