Files
HiveGrid-Wireframes/hivegrid-cloud/docs/features/compliance-audit.md
T

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. Optional tenantId filter.
  • /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

  1. Generate a secret: openssl rand -base64 32.
  2. Set AUDIT_CHAIN_SECRET=<value> in Coolify (platform-wide, never per-tenant).
  3. Apply the Prisma migration 20260517180000_10_audit_event_compliance.

Self-service export (tenant admin)

  1. Navigate to Settings → Audit Export.
  2. Pick date range + filters.
  3. Click Download CSV.
  4. Verify the first line shows ok=true. If ok=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)
  • prisma/migrations/20260517180000_10_audit_event_compliance/
  • packages/db/src/audit-hash.ts — chain helpers
  • packages/db/src/audit-events.server.ts — single write-path
  • apps/api/src/middleware/audit-context.ts — API auto-capture
  • apps/web/app/utils/audit/withAuditContext.ts — web auto-capture
  • apps/web/app/routes/admin/audit-export.tsx — admin export UI
  • apps/web/app/routes/app.$tenant/settings/audit-export.tsx — tenant export UI
  • apps/worker/src/jobs/audit-retention.ts — retention worker