Files
agentic-dev-template/docs/decisions/adr-018-audit-and-compliance.md
Danijel Martinek dd339b11b1 feat(core-shared): extend audit action enum with consent and restriction types
Adds CONSENT_GRANT, CONSENT_WITHDRAW, RESTRICT, UNRESTRICT to the
AuditAction closed enum per GDPR Art. 7 and Art. 18 requirements.

core-consent and core-dsr optional cores (Epic B Stories 03/06) emit
these action types via core-audit's IAuditLog channel; the values must
exist in core-shared's enum before either optional core can be built.
No change to IAuditLog's interface surface — new values flow through
AuditEntry.action automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 10:16:30 +00:00

103 lines
5.3 KiB
Markdown

# ADR-018 — Audit Logging & DPA Compliance
**Status:** Accepted
**Date:** 2026-05-11
**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/CONSENT_GRANT/CONSENT_WITHDRAW/RESTRICT/UNRESTRICT;
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.
## Amendments
### 2026-05-19 — Consent and restriction action types (Epic B, ADR-025)
Added four new `AuditAction` values to `core-shared/audit/audit-entry.ts`:
| Action | Article | Description |
| ------------------ | ------------ | ------------------------------------------------- |
| `CONSENT_GRANT` | GDPR Art. 7 | Subject granted consent for a processing purpose |
| `CONSENT_WITHDRAW` | GDPR Art. 7 | Subject withdrew consent for a processing purpose |
| `RESTRICT` | GDPR Art. 18 | Subject requested restriction of processing |
| `UNRESTRICT` | GDPR Art. 18 | Restriction lifted (controller or subject action) |
**Reason:** `core-consent` and `core-dsr` optional packages (Story 03 and 06
of Epic B) emit these action types via `core-audit`'s existing `IAuditLog`
channel. The values must exist in `core-shared`'s closed enum before either
optional core can be implemented. No change to `IAuditLog`'s interface surface —
the new values flow through `AuditEntry.action` automatically.