docs(spec): audit logging & DPA compliance design
Six-phase design for @repo/core-audit (5th optional package). Phase 1: AuditLogProtocol + AuditEntry type + truncateIp helper in core-shared; BindContext.auditLog? optional field. Phase 2: @repo/core-audit package with NoopAuditLog + PayloadAuditLog (append-only collection) + StdoutJsonAuditLog (structured JSON) + MultiSinkAuditLog fan-out wrapper + RecordingAuditLog in core-testing. Phase 3: GDPR erasure plumbing — eraseSubject impls, pseudonymize helper (sha256-with-salt), admin tRPC procedure, createAuditErasureHook Payload afterDelete hook factory. Phase 4: OTel correlation bridge — currentTraceId() helper, TraceIdEnrichingAuditLog decorator wraps inner sinks at bind time so AuditEntry.correlationId auto-populates from active OTel span. Phase 5: createAuditAfterReadHook factory for opt-in per-collection automatic VIEW capture; reference wiring documented (printed by generator as a diff, NOT auto-installed in auth). Phase 6: ADR-018, audit-and-compliance.md guide, generator template + byte-identical snapshot + e2e test, doc refreshes (CLAUDE.md, AGENTS.md, template-tiers, data-flow-explainer, README, scaffolding-doc). Compliance grounded in DPA "Logging & Monitoring" requirements: closed action enum (VIEW/CREATE/UPDATE/DELETE/EXPORT/PERMISSION_CHANGE), required tenant field, type-enforced "what NOT to log" (no payload/ body/oldValue/newValue fields), IP /24 v4 + /48 v6 truncation, sha256- salted pseudonymization, append-only Payload collection with privileged overrideAccess erasure path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
507
docs/superpowers/specs/2026-05-11-audit-and-compliance-design.md
Normal file
507
docs/superpowers/specs/2026-05-11-audit-and-compliance-design.md
Normal file
@@ -0,0 +1,507 @@
|
||||
# Audit logging & DPA compliance — Design
|
||||
|
||||
**Date:** 2026-05-11
|
||||
**Status:** Draft (pending user review)
|
||||
**Companion ADR:** ADR-018 (created in Phase 6).
|
||||
**Builds on:** ADR-014 (instrumentation interfaces — R31-R51 carry over), ADR-017 (OTel migration — provides `currentTraceId()` helper used by the OTel correlation bridge).
|
||||
**Compliance reference:** DPA "Logging & Monitoring" requirements.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context and motivation
|
||||
|
||||
DPA compliance mandates audit logging for every personal-data access event: VIEW / CREATE / UPDATE / DELETE / EXPORT / PERMISSION_CHANGE. Each entry must record who (user id, never email/name), what (action + collection + record id + tenant id), when (ISO 8601), and from where (IP truncated to /24 IPv4 or /48 IPv6, plus user agent). Logs must be immutable (append-only, separate access controls), deletable on GDPR erasure request, and ship to centralized aggregation (stdout-only is insufficient).
|
||||
|
||||
Critically, the contract must prevent categories of mistakes: passwords, OTP codes, reset tokens, MFA secrets, full user objects, and document contents must NEVER be logged. This is enforced by construction — `AuditEntry` has no `payload`/`body`/`oldValue`/`newValue` fields.
|
||||
|
||||
The observability stack (Sentry → OTel from ADR-017) is the wrong fit for this: observability data is sampled, debugging-oriented, short-retention. Audit data is lossless, compliance-grade, long-retention with a privileged erasure path. The two channels share field names (timestamp, actor, action, correlation id) but have different durability contracts and access controls.
|
||||
|
||||
This spec ships `@repo/core-audit` as the 5th optional package (joining realtime, events, trpc, ui), with the `AuditLogProtocol` in must-have `core-shared`. Feature code calls `ctx.auditLog?.record(entry)` without importing the optional package. The OTel `correlationId` field links audit entries to traces for compliance auditors to pivot from "who accessed record X" to "what was the request flow."
|
||||
|
||||
## 2. Decision summary
|
||||
|
||||
1. **`AuditLogProtocol` in `core-shared/di/bind-protocols.ts`** — minimal `{ record(entry): Promise<void> }`. Features depend on `core-shared`, never on optional `@repo/core-audit`. `IAuditLog` (in the optional package) extends the protocol with the privileged `eraseSubject(actorId, mode)`.
|
||||
2. **`AuditEntry` type in `core-shared/src/audit/audit-entry.ts`** — closed action enum, required `tenant` field (single-tenant projects use `"default"`), required `containsPii` flag, no payload-shaped fields by construction.
|
||||
3. **`truncateIp(raw)` helper in `core-shared/src/audit/truncate-ip.ts`** — enforces /24 IPv4 + /48 IPv6 truncation; throws on malformed input.
|
||||
4. **`BindContext.auditLog?` field** — 5th generic `Audit extends AuditLogProtocol = AuditLogProtocol`. Backward-compatible with existing 4-generic callers.
|
||||
5. **`@repo/core-audit` optional package** — `IAuditLog`, `NoopAuditLog`, `PayloadAuditLog` (local cache; append-only collection), `StdoutJsonAuditLog` (structured JSON to stdout for log-shipper consumption), `MultiSinkAuditLog` (fan-out wrapper), `RecordingAuditLog` (in `core-testing`).
|
||||
6. **Append-only Payload collection** — `update: () => false` access rule; erasure uses `overrideAccess: true` for privileged ops.
|
||||
7. **GDPR erasure plumbing** — `eraseSubject(actorId, mode: "pseudonymize" | "delete")`. Pseudonymize replaces actorId with `erased-{sha256(salt+id)[0:16]}`. Salt from `AUDIT_PSEUDONYM_SALT` env (validated in production binding).
|
||||
8. **Erasure trigger surface** — admin tRPC procedure (`audit.eraseSubject`), Payload `afterDelete` hook factory (`createAuditErasureHook`), auth integration via printed generator next-steps (NOT auto-installed).
|
||||
9. **OTel correlation bridge** — `currentTraceId()` helper in `core-shared/instrumentation/otel/`; `TraceIdEnrichingAuditLog` decorator at bind time auto-populates `AuditEntry.correlationId` from the active OTel span; explicit caller value wins.
|
||||
10. **VIEW capture via BOTH patterns** — use-case `record()` calls (developer decides per-read-path) AND `createAuditAfterReadHook` Payload hook factory (opt-in per-collection automatic capture). Fire-and-forget semantics for hooks; sink failures emit structured errors to stderr.
|
||||
11. **IP/UA explicit at call sites** — no `AsyncLocalStorage`. Callers gather IP+UA from the request, call `truncateIp()`, pass into `record({ from: { ipTruncated, userAgent } })`. Sentinels for non-HTTP context: `"system"` / `"background-job"`.
|
||||
12. **Six-phase delivery** matching the established cadence (events, realtime, core-package, OTel).
|
||||
|
||||
## 3. Architecture overview
|
||||
|
||||
| Phase | Ships | Verification |
|
||||
|---|---|---|
|
||||
| 1 | `AuditLogProtocol` + `AuditEntry` type + `truncateIp` helper in `core-shared`; `BindContext.auditLog?` field; no impl | Type-only changes; existing tests pass; `BindContext` backward-compatible |
|
||||
| 2 | `@repo/core-audit` package with 4 impls (Noop, Payload, StdoutJson, MultiSink); `RecordingAuditLog` in `core-testing`; `bindAudit` binder; `auditLogs` Payload collection definition | Each impl unit-tested; fan-out asserts settle-all + stderr error path |
|
||||
| 3 | GDPR erasure: `eraseSubject` impls, `pseudonymize` helper, admin tRPC procedure (`audit.eraseSubject`), `createAuditErasureHook` factory | Erase tests: pseudonymize replaces actorId; delete removes entries; tRPC admin-guarded |
|
||||
| 4 | OTel correlation: `currentTraceId()` helper; `TraceIdEnrichingAuditLog` decorator applied at `bindAudit` time | Tests assert traceId auto-populated inside `withSpan`; explicit caller value preserved |
|
||||
| 5 | VIEW capture: `createAuditAfterReadHook` factory; reference wiring documented (NOT auto-installed in auth) | Hook tests: VIEW entries created per read; `shouldSkip` respected; fire-and-forget semantics |
|
||||
| 6 | ADR-018; `docs/guides/audit-and-compliance.md`; generator template at `turbo/generators/templates/core-package/audit/`; byte-identical snapshot + e2e test; CLAUDE.md / AGENTS.md / template-tiers / README / data-flow-explainer / scaffolding-doc updates | All gates green; e2e byte-identical reconstruction passes |
|
||||
|
||||
## 4. Phase 1 — Protocol + AuditEntry type
|
||||
|
||||
### 4.1 `AuditLogProtocol`
|
||||
|
||||
In `packages/core-shared/src/di/bind-protocols.ts`:
|
||||
|
||||
```ts
|
||||
export type AuditLogProtocol = {
|
||||
record(entry: AuditEntry): Promise<void>;
|
||||
};
|
||||
```
|
||||
|
||||
`eraseSubject` is NOT on the protocol — it's a privileged operation that only the full `IAuditLog` interface (in `@repo/core-audit`) exposes. Feature binders see only `record`; admin-path code that needs erasure imports `IAuditLog` directly.
|
||||
|
||||
### 4.2 `AuditEntry` type
|
||||
|
||||
In `packages/core-shared/src/audit/audit-entry.ts`:
|
||||
|
||||
```ts
|
||||
export type AuditAction =
|
||||
| "VIEW"
|
||||
| "CREATE"
|
||||
| "UPDATE"
|
||||
| "DELETE"
|
||||
| "EXPORT"
|
||||
| "PERMISSION_CHANGE";
|
||||
|
||||
export type AuditFrom = {
|
||||
ipTruncated: string;
|
||||
userAgent: string;
|
||||
};
|
||||
|
||||
export type AuditEntry = {
|
||||
// WHO
|
||||
actorId: string;
|
||||
actorType: "user" | "system" | "service";
|
||||
actorRoles: string[]; // snapshot at time of action
|
||||
|
||||
// WHAT
|
||||
action: AuditAction;
|
||||
resource: { type: string; id?: string };
|
||||
changedFields?: string[]; // UPDATE only; field names, not values
|
||||
|
||||
// WHEN
|
||||
at: Date;
|
||||
|
||||
// SCOPE (where)
|
||||
scope: {
|
||||
feature: string;
|
||||
environment: string;
|
||||
tenant: string; // REQUIRED; "default" for single-tenant
|
||||
};
|
||||
|
||||
// WHY (reason + context)
|
||||
reason?: string;
|
||||
correlationId?: string; // OTel trace ID; auto-populated by bind-time decorator
|
||||
requestId?: string;
|
||||
|
||||
// FROM (per DPA)
|
||||
from: AuditFrom;
|
||||
|
||||
// PII CLASSIFICATION
|
||||
containsPii: boolean;
|
||||
/**
|
||||
* Free-form list of PII categories present in the audited resource.
|
||||
* Conventions (suggested, not enforced): "email", "name", "phone",
|
||||
* "address", "ssn", "financial", "health". Free-form rather than
|
||||
* closed enum because regulatory classifications differ by jurisdiction
|
||||
* and the caller knows the resource shape best.
|
||||
*/
|
||||
piiCategories?: string[];
|
||||
|
||||
// OUTCOME
|
||||
outcome: "success" | "denied" | "error";
|
||||
errorCode?: string;
|
||||
};
|
||||
```
|
||||
|
||||
### 4.3 `truncateIp` helper
|
||||
|
||||
In `packages/core-shared/src/audit/truncate-ip.ts`:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* IPv4: "192.168.1.42" → "192.168.1.0" (/24)
|
||||
* IPv6: "2001:0db8:1234:5678:..." → "2001:db8:1234::" (/48)
|
||||
* Throws on malformed input — compliance regimes prefer hard failure
|
||||
* over partial scrubbing.
|
||||
*/
|
||||
export function truncateIp(raw: string): string {
|
||||
if (raw.includes(":")) {
|
||||
const parts = raw.toLowerCase().split(":").filter((p) => p !== "");
|
||||
if (parts.length < 3) {
|
||||
throw new Error(`truncateIp: malformed IPv6 address "${raw}"`);
|
||||
}
|
||||
return `${parts[0]}:${parts[1]}:${parts[2]}::`;
|
||||
}
|
||||
const parts = raw.split(".");
|
||||
if (parts.length !== 4 || parts.some((p) => isNaN(Number(p)))) {
|
||||
throw new Error(`truncateIp: malformed IPv4 address "${raw}"`);
|
||||
}
|
||||
return `${parts[0]}.${parts[1]}.${parts[2]}.0`;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 `BindContext` extension
|
||||
|
||||
5th generic param:
|
||||
|
||||
```ts
|
||||
export type BindContext<
|
||||
Bus extends EventBusProtocol = EventBusProtocol,
|
||||
Realtime extends RealtimeBroadcasterProtocol = RealtimeBroadcasterProtocol,
|
||||
RealtimeReg extends RealtimeRegistryProtocol = RealtimeRegistryProtocol,
|
||||
Metrics extends MetricsProtocol = MetricsProtocol,
|
||||
Audit extends AuditLogProtocol = AuditLogProtocol,
|
||||
> = BindContextBase & {
|
||||
bus?: Bus;
|
||||
queue?: IJobQueue;
|
||||
realtime?: Realtime;
|
||||
realtimeRegistry?: RealtimeReg;
|
||||
metrics?: Metrics;
|
||||
auditLog?: Audit;
|
||||
};
|
||||
```
|
||||
|
||||
`BindProductionContext` extended in parallel.
|
||||
|
||||
### 4.5 Phase 1 files
|
||||
|
||||
- Create: `packages/core-shared/src/audit/audit-entry.ts` (+ test)
|
||||
- Create: `packages/core-shared/src/audit/truncate-ip.ts` (+ test)
|
||||
- Create: `packages/core-shared/src/audit/index.ts` (barrel)
|
||||
- Modify: `packages/core-shared/src/di/bind-protocols.ts` (add `AuditLogProtocol`)
|
||||
- Modify: `packages/core-shared/src/di/bind-context.ts` (add 5th generic + `auditLog?` field)
|
||||
- Modify: `packages/core-shared/src/index.ts` (re-export from `./audit`)
|
||||
- Modify: `packages/core-shared/package.json` (subpath export `./audit`)
|
||||
|
||||
## 5. Phase 2 — `@repo/core-audit` package with impls
|
||||
|
||||
### 5.1 Package structure
|
||||
|
||||
Mirrors core-events / core-realtime / core-trpc / core-ui shape:
|
||||
|
||||
```
|
||||
packages/core-audit/
|
||||
├── AGENTS.md
|
||||
├── eslint.config.js
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── turbo.json
|
||||
├── vitest.config.ts
|
||||
└── src/
|
||||
├── index.ts # Barrel
|
||||
├── audit-log.interface.ts # IAuditLog extends AuditLogProtocol
|
||||
├── audit-logs-collection.ts # Payload collection definition
|
||||
├── noop-audit-log.ts # + .test.ts
|
||||
├── payload-audit-log.ts # + .test.ts
|
||||
├── stdout-json-audit-log.ts # + .test.ts
|
||||
├── multi-sink-audit-log.ts # + .test.ts
|
||||
├── di/
|
||||
│ ├── bind-audit.ts # + .test.ts
|
||||
│ └── symbols.ts # AUDIT_SYMBOLS.IAuditLog
|
||||
├── integrations/
|
||||
│ └── api/ # (populated in Phase 3)
|
||||
└── hooks/ # (populated in Phase 3 and Phase 5)
|
||||
```
|
||||
|
||||
### 5.2 `IAuditLog`
|
||||
|
||||
```ts
|
||||
import type { AuditLogProtocol, AuditEntry } from "@repo/core-shared/audit";
|
||||
|
||||
export interface IAuditLog extends AuditLogProtocol {
|
||||
// record(entry: AuditEntry) inherited
|
||||
eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 `auditLogs` Payload collection
|
||||
|
||||
Append-only by config (`update: () => false`); admin-only read and delete. Field set:
|
||||
|
||||
- `actorId`, `actorType`, `actorRoles` (json), `action` (enum), `resourceType`, `resourceId`
|
||||
- `changedFields` (json), `scopeFeature`, `scopeEnvironment`, `scopeTenant`
|
||||
- `reason`, `correlationId`, `requestId`, `ipTruncated`, `userAgent`
|
||||
- `containsPii` (checkbox), `piiCategories` (json), `outcome` (enum), `errorCode`
|
||||
- Payload's built-in `createdAt` maps to `AuditEntry.at`
|
||||
|
||||
Indexed columns: `actorId`, `action`, `resourceType`, `scopeFeature`, `scopeTenant`, `correlationId`.
|
||||
|
||||
### 5.4 Impls
|
||||
|
||||
- **`NoopAuditLog`** — both methods no-op.
|
||||
- **`PayloadAuditLog`** — calls `payload.create({ collection: "audit-logs", data })`. `eraseSubject` impl lands in Phase 3.
|
||||
- **`StdoutJsonAuditLog`** — `process.stdout.write(JSON.stringify({...entry, at: entry.at.toISOString(), _type: "audit"}) + "\n")`. Erasure emits a `_type: "audit-erasure"` tombstone (best-effort; past lines can't be retroactively removed; downstream aggregator handles real erasure).
|
||||
- **`MultiSinkAuditLog`** — `Promise.allSettled` over inner sinks; failures emit `_type: "audit-sink-error"` to stderr (avoids recursion via Sentry/OTel).
|
||||
- **`RecordingAuditLog`** (in `core-testing`) — collects `recorded[]` and `erasures[]`. Mirrors Payload semantics in `eraseSubject` for test fidelity.
|
||||
|
||||
### 5.5 `bindAudit` binder
|
||||
|
||||
```ts
|
||||
export function bindAudit(
|
||||
container: Container,
|
||||
opts: { payloadConfig?: SanitizedConfig; sinks?: ("payload" | "stdout")[] } = {},
|
||||
): { auditLog: IAuditLog } {
|
||||
const sinkList = opts.sinks ?? ["payload", "stdout"];
|
||||
const sinks: IAuditLog[] = [];
|
||||
if (sinkList.includes("payload") && opts.payloadConfig) {
|
||||
sinks.push(new PayloadAuditLog(opts.payloadConfig));
|
||||
}
|
||||
if (sinkList.includes("stdout")) {
|
||||
sinks.push(new StdoutJsonAuditLog());
|
||||
}
|
||||
const inner = sinks.length > 1 ? new MultiSinkAuditLog(sinks)
|
||||
: sinks.length === 1 ? sinks[0]!
|
||||
: new NoopAuditLog();
|
||||
const auditLog: IAuditLog = new TraceIdEnrichingAuditLog(inner); // wrapper from Phase 4
|
||||
container.bind<IAuditLog>(AUDIT_SYMBOLS.IAuditLog).toConstantValue(auditLog);
|
||||
return { auditLog };
|
||||
}
|
||||
```
|
||||
|
||||
Note: `TraceIdEnrichingAuditLog` is added in Phase 4. In Phase 2, the binder returns the inner sink directly; Phase 4 introduces the wrapper.
|
||||
|
||||
## 6. Phase 3 — GDPR erasure plumbing
|
||||
|
||||
### 6.1 `pseudonymize` helper
|
||||
|
||||
```ts
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export function pseudonymize(actorId: string): string {
|
||||
const salt = process.env.AUDIT_PSEUDONYM_SALT ?? "dev-fallback-salt-replace-in-prod";
|
||||
const hash = createHash("sha256").update(salt + ":" + actorId).digest("hex");
|
||||
return `erased-${hash.slice(0, 16)}`;
|
||||
}
|
||||
```
|
||||
|
||||
Production-binding code (in `bindAudit`) validates `process.env.AUDIT_PSEUDONYM_SALT` is set when `NODE_ENV === "production"`; throws at boot if absent.
|
||||
|
||||
### 6.2 `PayloadAuditLog.eraseSubject` impl
|
||||
|
||||
- Mode `"delete"`: `payload.delete({ collection: "audit-logs", where: { actorId: { equals } }, overrideAccess: true })`.
|
||||
- Mode `"pseudonymize"`: fetch matching docs (`payload.find` with `overrideAccess: true`), iterate and `payload.update({ id, data: { actorId: pseudonym }, overrideAccess: true })`. Bypasses `update: () => false` via the documented Payload escape hatch.
|
||||
|
||||
`StdoutJsonAuditLog.eraseSubject` emits the tombstone (described in Phase 2).
|
||||
|
||||
### 6.3 `createAuditErasureHook` Payload hook factory
|
||||
|
||||
```ts
|
||||
export type AuditErasureHookOpts = {
|
||||
auditLog: IAuditLog;
|
||||
mode?: "pseudonymize" | "delete";
|
||||
};
|
||||
|
||||
export function createAuditErasureHook(opts: AuditErasureHookOpts): CollectionAfterDeleteHook {
|
||||
const mode = opts.mode ?? "pseudonymize";
|
||||
return async ({ doc }) => {
|
||||
if (typeof doc.id === "string" || typeof doc.id === "number") {
|
||||
await opts.auditLog.eraseSubject(String(doc.id), mode);
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Hook factory has no schema-specific knowledge — works on any collection with an `id` field.
|
||||
|
||||
### 6.4 Admin tRPC procedure
|
||||
|
||||
```ts
|
||||
// packages/core-audit/src/integrations/api/router.ts
|
||||
export const auditRouter = t.router({
|
||||
eraseSubject: auditProcedure
|
||||
.input(z.object({
|
||||
actorId: z.string().min(1),
|
||||
mode: z.enum(["pseudonymize", "delete"]).default("pseudonymize"),
|
||||
}).strict())
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!ctx.auditLog) throw new Error("Audit not bound");
|
||||
await ctx.auditLog.eraseSubject(input.actorId, input.mode);
|
||||
return { ok: true as const };
|
||||
}),
|
||||
});
|
||||
|
||||
export type AuditRouter = typeof auditRouter;
|
||||
```
|
||||
|
||||
`auditProcedure` (in `procedures.ts`) uses an `adminOnly` middleware that throws `TRPCError({ code: "FORBIDDEN" })` if the user lacks the admin role.
|
||||
|
||||
### 6.5 Phase 3 files
|
||||
|
||||
- Create: `packages/core-audit/src/pseudonymize.ts` (+ test)
|
||||
- Modify: `packages/core-audit/src/payload-audit-log.ts` (eraseSubject impl)
|
||||
- Create: `packages/core-audit/src/hooks/audit-erasure-hook.ts` (+ test)
|
||||
- Create: `packages/core-audit/src/hooks/index.ts` (barrel)
|
||||
- Create: `packages/core-audit/src/integrations/api/procedures.ts`
|
||||
- Create: `packages/core-audit/src/integrations/api/router.ts` (+ test)
|
||||
- Modify: `packages/core-audit/src/index.ts` (re-exports)
|
||||
- Modify: `packages/core-audit/package.json` (subpath exports `./api`, `./hooks`)
|
||||
|
||||
## 7. Phase 4 — OTel correlation bridge
|
||||
|
||||
### 7.1 `currentTraceId()` helper
|
||||
|
||||
```ts
|
||||
// packages/core-shared/src/instrumentation/otel/current-trace-id.ts
|
||||
import { trace } from "@opentelemetry/api";
|
||||
|
||||
export function currentTraceId(): string | undefined {
|
||||
const span = trace.getActiveSpan();
|
||||
if (!span) return undefined;
|
||||
const ctx = span.spanContext();
|
||||
if (!ctx.traceId || /^0+$/.test(ctx.traceId)) return undefined;
|
||||
return ctx.traceId;
|
||||
}
|
||||
```
|
||||
|
||||
Re-exported from `core-shared/instrumentation` barrel.
|
||||
|
||||
### 7.2 `TraceIdEnrichingAuditLog` decorator
|
||||
|
||||
```ts
|
||||
import { currentTraceId } from "@repo/core-shared/instrumentation";
|
||||
|
||||
export class TraceIdEnrichingAuditLog implements IAuditLog {
|
||||
constructor(private readonly inner: IAuditLog) {}
|
||||
|
||||
async record(entry: AuditEntry): Promise<void> {
|
||||
if (entry.correlationId) return this.inner.record(entry);
|
||||
const traceId = currentTraceId();
|
||||
if (!traceId) return this.inner.record(entry);
|
||||
return this.inner.record({ ...entry, correlationId: traceId });
|
||||
}
|
||||
|
||||
eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
|
||||
return this.inner.eraseSubject(actorId, mode);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Applied at bind time (Phase 2's `bindAudit` is updated in Phase 4 to wrap the inner sink/fan-out). Single source of truth for trace correlation; sinks see entries that already have `correlationId` populated.
|
||||
|
||||
### 7.3 Phase 4 files
|
||||
|
||||
- Create: `packages/core-shared/src/instrumentation/otel/current-trace-id.ts` (+ test)
|
||||
- Modify: `packages/core-shared/src/instrumentation/otel/index.ts` (re-export)
|
||||
- Modify: `packages/core-shared/src/instrumentation/index.ts` (re-export)
|
||||
- Create: `packages/core-audit/src/trace-id-enriching-audit-log.ts` (+ test)
|
||||
- Modify: `packages/core-audit/src/di/bind-audit.ts` (wrap inner with `TraceIdEnrichingAuditLog`)
|
||||
- Modify: `packages/core-audit/src/index.ts` (re-export)
|
||||
|
||||
## 8. Phase 5 — VIEW capture wiring
|
||||
|
||||
### 8.1 `createAuditAfterReadHook` factory
|
||||
|
||||
Full signature in §6 above. Key semantics:
|
||||
|
||||
- **Fire-and-forget**: `void auditLog.record(entry).catch(...)` — a failing audit sink MUST NEVER break a user-facing read.
|
||||
- **Sentinel IP/UA values for non-HTTP context**: `"internal"` / `"payload-internal"` (Payload reads from background jobs, admin UI).
|
||||
- **Required `containsPii` + `resourceType` at hook-creation time** — declared per-collection, propagated to every entry.
|
||||
- **`shouldSkip` predicate** — opt-out for high-traffic non-sensitive reads.
|
||||
|
||||
### 8.2 Reference wiring (NOT auto-installed)
|
||||
|
||||
The auth feature's `bind-production.ts` does NOT import from `@repo/core-audit` directly out of the box. The hook is installed via dynamic import in a bind-time block that the generator's next-steps print as a diff to apply:
|
||||
|
||||
```ts
|
||||
if (ctx.auditLog) {
|
||||
const { createAuditAfterReadHook, createAuditErasureHook } =
|
||||
await import("@repo/core-audit/hooks");
|
||||
// ... install on users collection
|
||||
}
|
||||
```
|
||||
|
||||
This keeps `core-audit` truly optional — auth has no hard module-load dependency on it.
|
||||
|
||||
### 8.3 Phase 5 files
|
||||
|
||||
- Create: `packages/core-audit/src/hooks/audit-after-read-hook.ts` (+ test)
|
||||
- Modify: `packages/core-audit/src/hooks/index.ts` (re-export new factory)
|
||||
- Modify: `packages/core-audit/src/index.ts` (re-export)
|
||||
|
||||
## 9. Phase 6 — ADR-018 + generator template + docs
|
||||
|
||||
### 9.1 ADR-018
|
||||
|
||||
Status Accepted; date 2026-05-11; cites spec + plan paths; 12 decision points (the decision-summary list above). Supersedes nothing; relates to ADR-014 (interface decisions R31-R51 carry over), ADR-015 (events/jobs — no overlap), ADR-017 (OTel migration — provides `currentTraceId()`).
|
||||
|
||||
### 9.2 Generator template
|
||||
|
||||
Capture all `packages/core-audit/` files as `.hbs` templates under `turbo/generators/templates/core-package/audit/`. Generate byte-identical snapshot at `turbo/generators/__snapshots__/core-package/audit.snapshot.json`. e2e test at `turbo/generators/__tests__/core-package-audit.e2e.test.ts` (mirrors the existing per-package e2e pattern).
|
||||
|
||||
Add `audit` to the `core-package` generator's `choices` list and `CORE_PACKAGE_GENERATORS` dispatch table:
|
||||
|
||||
```ts
|
||||
CORE_PACKAGE_GENERATORS["audit"] = () => [
|
||||
() => assertOptionalPackageNotPresent("core-audit"),
|
||||
...emitTemplateTree("core-package/audit", "packages/core-audit"),
|
||||
() => addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-audit"),
|
||||
() => printAuditNextSteps(),
|
||||
];
|
||||
```
|
||||
|
||||
`printAuditNextSteps()` prints the 7-step manual wiring guide (env salt, collection mount, tRPC mount, app aggregator bind, auth-hook diff, log-shipper setup, verify gates).
|
||||
|
||||
### 9.3 Docs
|
||||
|
||||
- **ADR-018** (per above)
|
||||
- **`docs/guides/audit-and-compliance.md`** — full how-to guide. Sections:
|
||||
1. What DPA requires (summary of the compliance doc)
|
||||
2. Two-pattern model (use-case calls vs Payload hooks)
|
||||
3. When to use which pattern
|
||||
4. Wiring core-audit into your app (mirrors the print)
|
||||
5. Sample Vector + Fluent Bit configs (stdout → Grafana Loki EU)
|
||||
6. GDPR erasure via admin tRPC
|
||||
7. Sample-week audit verification ("who accessed any given record")
|
||||
8. Hostile-actor immutability test
|
||||
9. Common mistakes
|
||||
- **`docs/architecture/template-tiers.md`** — add `core-audit` row to the optional table
|
||||
- **`docs/scaffolding/core-package-generator.md`** — add `audit` row to the templates table
|
||||
- **`CLAUDE.md`** — Project Overview lists 5 optionals (realtime/events/trpc/ui/audit); "Read first" adds `docs/guides/audit-and-compliance.md` with prerequisite note
|
||||
- **`AGENTS.md`** — package tags list adds `@repo/core-audit` (optional); generator list mentions audit alongside the existing four
|
||||
- **`docs/architecture/data-flow-explainer.html`** — audit-layer arrows marked conditional/optional
|
||||
- **`README.md`** — Optional packages block adds `pnpm turbo gen core-package audit`
|
||||
|
||||
### 9.4 Phase 6 files (counts)
|
||||
|
||||
- Create: 1 ADR, 1 guide, ~12 generator template files (`audit/**`), 1 snapshot JSON, 1 e2e test
|
||||
- Modify: `turbo/generators/config.ts`, `template-tiers.md`, `core-package-generator.md`, `CLAUDE.md`, `AGENTS.md`, `data-flow-explainer.html`, `README.md`
|
||||
|
||||
## 10. Testing strategy
|
||||
|
||||
**Per-phase unit tests** — each new file gets a Vitest test. Type-level assertions for `AuditEntry` shape. Recording test double in `core-testing` follows the existing pattern.
|
||||
|
||||
**Phase 6 e2e test** — byte-identical reconstruction of `@repo/core-audit` from the generator template; runs `pnpm install + lint + typecheck + test + boundaries` against the reconstructed workspace. Mirrors the four existing per-package e2e tests.
|
||||
|
||||
**Manual smoke (post-Phase 6)** — scaffold core-audit into a workspace, wire the 7 manual next-steps, trigger sign-in (CREATE entry), profile read (VIEW entry), deletion (DELETE + erasure). Verify entries land in the Payload `auditLogs` collection AND on stdout as structured JSON.
|
||||
|
||||
**Verification gates per phase** — `pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries` all green. No phase commits with regressions.
|
||||
|
||||
## 11. ESLint allowlist
|
||||
|
||||
No new rules. `packages/core-audit/` matches the `packages/core-*` wildcard in `boundaries/elements` (in `core-eslint/base.js`) so it's automatically classified as a `core` package. The "what NOT to log" enforcement is type-level (no `payload`/`body`/`oldValue`/`newValue` fields on `AuditEntry`), not lint-level.
|
||||
|
||||
## 12. Out of scope
|
||||
|
||||
- **Per-collection UPDATE value capture.** v1 captures field NAMES only (`changedFields`). Value capture (for non-sensitive fields) is a future opt-in mechanism (per-collection allowlist of field names → values captured in a separate `valueDiff` field).
|
||||
- **External-aggregator HTTP sinks** (LokiAuditLog, DatadogAuditLog, AxiomAuditLog). v1 ships stdout JSON + a Payload local cache. Direct vendor sinks land when a project's log-shipper infrastructure is insufficient. Architecture supports them — `IAuditLog` extension only.
|
||||
- **OTel Logs API integration.** Audit is a parallel channel, NOT a signal flowing through OTel (different durability + retention contract). The `correlationId` field is the only bridge.
|
||||
- **Browser-side audit logging.** Phase scope is server-only (mirrors ADR-017's server-only OTel migration). Browser-emitted audit entries (e.g., page view of a sensitive document) require a future spec.
|
||||
- **Audit log replay / forensic timeline UI.** Compliance auditors query via Payload admin (`auditLogs` collection) or the external aggregator (Grafana/Loki/Datadog UI). A dedicated in-app forensic UI is future work.
|
||||
- **Retention policy automation.** v1 documents the 90-day-hot / 1-year-archive policy in the guide. Automation (background job that prunes old entries) is a follow-up spec.
|
||||
- **`ObservableGauge`-style audit "current state" probes.** Audit captures events, not state snapshots.
|
||||
|
||||
## 13. Related
|
||||
|
||||
- ADR-014 — instrumentation interfaces (R31-R51 carry over, especially R36 "user id only, never email/name")
|
||||
- ADR-015 — events and jobs (no overlap; audit is observational, events are reactive)
|
||||
- ADR-017 — OTel migration (provides `currentTraceId()` helper used by the OTel bridge)
|
||||
- DPA compliance doc (user-provided; the source of truth for action enum + required fields + retention + immutability requirements)
|
||||
Reference in New Issue
Block a user