6.5 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. Without
AUDIT_CHAIN_SECRET the helper falls back to plain SHA-256 — still
tamper-evident for read-only attackers but trivially forgeable.
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.
Plan-based defaults (resolveAuditRetentionPolicy):
| Regulatory scope on tenant events | Retention |
|---|---|
soc2 or hipaa |
7 years |
gdpr |
6 years |
| none | 1 year |
Every non-empty sweep emits a meta-audit (audit.retention.swept) 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 Prisma migration
20260517180000_10_audit_event_compliance.
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("@toolctrl/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