From 2b0c54c0132c8312ad2eb91fb24bcc8f4a5f6c42 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 11 May 2026 16:36:34 +0200 Subject: [PATCH] docs(adr): ADR-018 audit logging & DPA compliance --- .../decisions/adr-018-audit-and-compliance.md | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/decisions/adr-018-audit-and-compliance.md diff --git a/docs/decisions/adr-018-audit-and-compliance.md b/docs/decisions/adr-018-audit-and-compliance.md new file mode 100644 index 0000000..8fabb5f --- /dev/null +++ b/docs/decisions/adr-018-audit-and-compliance.md @@ -0,0 +1,82 @@ +# ADR-018 — Audit Logging & DPA Compliance + +**Status:** Accepted +**Date:** 2026-05-11 +**Spec:** docs/superpowers/specs/2026-05-11-audit-and-compliance-design.md +**Plan:** docs/superpowers/plans/2026-05-11-audit-and-compliance.md +**Companion guide:** docs/guides/audit-and-compliance.md + +## Context + +DPA compliance mandates audit logging for every personal-data access event: +VIEW/CREATE/UPDATE/DELETE/EXPORT/PERMISSION_CHANGE, with immutable storage, +GDPR-deletable path, centralized aggregation, and strict "what NOT to log" +boundaries. The interface decisions from ADR-014 (R31-R51) carry over but +audit needs its own channel — observability data is sampled and short-retention, +audit data is lossless and long-retention with privileged erasure. + +## Decision (12 points) + +1. **`AuditLogProtocol` in `core-shared`** — must-have universal surface. + Features call `ctx.auditLog?.record(entry)` without importing the optional package. +2. **`AuditEntry` type with closed action enum** — VIEW/CREATE/UPDATE/DELETE/ + EXPORT/PERMISSION_CHANGE; new actions require explicit type bump. No + payload/body/oldValue/newValue fields — type enforces "what NOT to log". +3. **`@repo/core-audit` as 5th optional package** — joins realtime, events, + trpc, ui. Scaffolded via `pnpm turbo gen core-package audit`. +4. **Four impls + Recording test double**: NoopAuditLog, PayloadAuditLog + (local cache), StdoutJsonAuditLog (operator ships via Vector/Fluent Bit), + MultiSinkAuditLog (fan-out), RecordingAuditLog (core-testing). +5. **Append-only Payload collection** — `update: () => false` access rule + is the compliance backbone; erasure path uses `overrideAccess: true`. +6. **GDPR erasure** — sha256-salted pseudonymization (`erased-{hash[0:16]}`) + or hard delete. AUDIT_PSEUDONYM_SALT env REQUIRED in production; bind-time + validation fails fast. +7. **Erasure trigger surface** — admin tRPC procedure (`audit.eraseSubject`), + Payload `afterDelete` hook factory (`createAuditErasureHook`), auth + integration via printed generator next-steps (NOT auto-installed). +8. **OTel correlation bridge** — `currentTraceId()` helper in core-shared; + `TraceIdEnrichingAuditLog` decorator at bind time auto-populates + `AuditEntry.correlationId` from active OTel span. Explicit caller wins. +9. **VIEW capture via BOTH patterns** — use-case `record()` calls (developer + decides per-read-path) AND `createAuditAfterReadHook` factory (opt-in + per-collection automatic capture). Fire-and-forget for hooks. +10. **IP/UA explicit at call sites** — no AsyncLocalStorage. Callers use + `truncateIp(raw)` (/24 IPv4, /48 IPv6) and pass into `record({ from: { ... } })`. + Sentinels for non-HTTP context: `"system"` / `"background-job"`. +11. **Multi-tenancy: tenant field required** — `AuditEntry.scope.tenant` + non-optional; single-tenant projects pass `"default"`. Forces multi-tenant + thinking from day one. +12. **Six-phase delivery** matching established cadence. + +## Alternatives considered + +- **Vendor-coupled SDK (Datadog/Grafana direct)** — rejected; couples to vendor. +- **Payload-only sink** — fails compliance (hostile-actor immutability). +- **Aggregator-only sink** — fails dev ergonomics. Fan-out is the balance. +- **AsyncLocalStorage for request context** — rejected per user preference; + explicit > implicit. +- **Optional tenant field** — rejected; DPA-aligned scope discipline benefits + from forcing the question on every call. + +## Consequences + +**Positive:** +- DPA-compliant baseline ships with the optional package. +- Vendor-neutral via stdout JSON + log shipper; any aggregator works. +- OTel correlation gives compliance auditors one-click pivot to traces. +- Type-enforced exclusion of "what not to log" prevents categories of mistakes. + +**Negative:** +- Boilerplate at every record() call site (IP/UA explicit). +- core-audit ↔ auth coupling for the user-collection hook is awkward + (manual install via generator next-steps). +- StdoutJsonAuditLog's eraseSubject is best-effort (tombstone only; past + stdout lines can't be retroactively removed). + +## Relationship to other ADRs + +- ADR-014 (instrumentation interfaces): audit is a parallel channel, not a + signal flowing through OTel. The correlationId field is the bridge. +- ADR-015 (events/jobs): no overlap; audit is observational, events are reactive. +- ADR-017 (OTel migration): provides currentTraceId() helper.