Implementation plan for the spec at docs/superpowers/specs/2026-05-11- audit-and-compliance-design.md. Six phases: - Phase 1: AuditLogProtocol + AuditEntry type + truncateIp helper in core-shared; BindContext.auditLog? (5th generic). - Phase 2: @repo/core-audit optional package — NoopAuditLog, StdoutJsonAuditLog, append-only audit-logs Payload collection, PayloadAuditLog (record), MultiSinkAuditLog fan-out, bindAudit binder with prod salt validation, RecordingAuditLog in core-testing. - Phase 3: GDPR erasure plumbing — pseudonymize helper (sha256 + AUDIT_PSEUDONYM_SALT), PayloadAuditLog.eraseSubject via overrideAccess, createAuditErasureHook Payload afterDelete factory, admin tRPC procedure (audit.eraseSubject). - Phase 4: OTel correlation bridge — currentTraceId() in core-shared, TraceIdEnrichingAuditLog decorator wraps inner sinks at bindAudit time. Explicit caller correlationId wins. - Phase 5: createAuditAfterReadHook factory for opt-in per-collection VIEW capture; fire-and-forget semantics with stderr fallback. - Phase 6: ADR-018 + audit-and-compliance.md guide + generator template at turbo/generators/templates/core-package/audit/ + byte-identical snapshot + e2e test + 6 doc surface refreshes (template-tiers, scaffolding-doc, CLAUDE.md, AGENTS.md, data-flow-explainer, README). Total: ~30 commits expected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3260 lines
107 KiB
Markdown
3260 lines
107 KiB
Markdown
# Audit Logging & DPA Compliance Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Ship `@repo/core-audit` as the 5th optional core package, with `AuditLogProtocol` + `AuditEntry` type in must-have `core-shared`. Provides DPA-compliant audit logging: closed action enum (VIEW/CREATE/UPDATE/DELETE/EXPORT/PERMISSION_CHANGE), append-only Payload collection, structured JSON stdout sink, OTel trace correlation, GDPR erasure (pseudonymize/delete), and Payload `afterRead` + `afterDelete` hook factories.
|
|
|
|
**Architecture:** Six sequential phases. Phase 1 ships the universal kernel in core-shared (no impl). Phase 2 ships the optional package with all impls + binder. Phase 3 adds GDPR erasure plumbing (admin tRPC + Payload hook factory). Phase 4 adds the OTel correlation bridge. Phase 5 adds the `afterRead` hook factory for opt-in automatic VIEW capture. Phase 6 publishes ADR-018, the audit-and-compliance guide, and the generator template (so `pnpm turbo gen core-package audit` becomes available).
|
|
|
|
**Tech Stack:** TypeScript, Node 22, Vitest, Payload CMS, tRPC, OpenTelemetry API (`@opentelemetry/api`), Node crypto (sha256).
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-05-11-audit-and-compliance-design.md` — read first, especially §4 (Phase 1), §5 (Phase 2 package + impls), §6 (erasure), §7 (OTel bridge), §8 (VIEW capture).
|
|
|
|
**Phase numbering:** Plan uses `Phase 0 (Read first)` as preamble; work phases 1-6 map 1:1 to spec phases.
|
|
|
|
---
|
|
|
|
## Phase 0 — Read first
|
|
|
|
- [ ] **Step 1: Read the spec end-to-end**
|
|
|
|
Open `docs/superpowers/specs/2026-05-11-audit-and-compliance-design.md`. Pay close attention to §2 (12 decision points), §4 (`AuditEntry` type), §5.4 (impls), §6 (erasure), §7 (OTel bridge), §8 (VIEW capture).
|
|
|
|
- [ ] **Step 2: Read the DPA compliance reference**
|
|
|
|
The user-provided compliance doc in the spec's §1 (and the conversation that produced this plan). The 6 required actions, the 4 required fields (who/what/when/from_where), the immutability requirements, and the "what NOT to log" list are load-bearing.
|
|
|
|
- [ ] **Step 3: Skim existing optional package patterns**
|
|
|
|
Read the structure of the four existing optional packages in their template form: `turbo/generators/templates/core-package/{realtime,events,trpc,ui}/`. `@repo/core-audit` follows the same shape (package.json, eslint.config.js, tsconfig.json, turbo.json, vitest.config.ts, AGENTS.md, src/).
|
|
|
|
- [ ] **Step 4: Skim existing core-shared protocol pattern**
|
|
|
|
Read `packages/core-shared/src/di/bind-protocols.ts` end-to-end. `AuditLogProtocol` is added alongside the existing four (EventBus, RealtimeBroadcaster, RealtimeRegistry, Metrics). Same pattern: minimal surface, optional package extends with full interface.
|
|
|
|
- [ ] **Step 5: Skim OTel `currentTraceId` callsite**
|
|
|
|
The `currentTraceId()` helper used in Phase 4 lives in `packages/core-shared/src/instrumentation/otel/`. The OTel migration (ADR-017) wired the active-span access via `@opentelemetry/api`'s `trace.getActiveSpan()`. Audit just reads from it.
|
|
|
|
---
|
|
|
|
## Phase 1 — Protocol + AuditEntry type in core-shared
|
|
|
|
**Goal:** Ship the universal kernel surface. `AuditLogProtocol` + `AuditEntry` type + `truncateIp` helper exist in `core-shared`. `BindContext.auditLog?` field added. No impl.
|
|
|
|
**Files touched:**
|
|
|
|
- Create: `packages/core-shared/src/audit/audit-entry.ts`
|
|
- Create: `packages/core-shared/src/audit/audit-entry.test.ts`
|
|
- Create: `packages/core-shared/src/audit/truncate-ip.ts`
|
|
- Create: `packages/core-shared/src/audit/truncate-ip.test.ts`
|
|
- Create: `packages/core-shared/src/audit/index.ts`
|
|
- Modify: `packages/core-shared/src/di/bind-protocols.ts`
|
|
- Modify: `packages/core-shared/src/di/bind-context.ts`
|
|
- Modify: `packages/core-shared/src/index.ts`
|
|
- Modify: `packages/core-shared/package.json`
|
|
|
|
### Task 1.1: AuditEntry type (TDD)
|
|
|
|
**Files:**
|
|
- Create: `packages/core-shared/src/audit/audit-entry.ts`
|
|
- Create: `packages/core-shared/src/audit/audit-entry.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing type-level test**
|
|
|
|
Create `packages/core-shared/src/audit/audit-entry.test.ts`:
|
|
|
|
```ts
|
|
import { describe, it, expectTypeOf } from "vitest";
|
|
import type { AuditEntry, AuditAction, AuditFrom } from "./audit-entry";
|
|
|
|
describe("AuditAction", () => {
|
|
it("is a closed enum of 6 values", () => {
|
|
expectTypeOf<AuditAction>().toEqualTypeOf<
|
|
"VIEW" | "CREATE" | "UPDATE" | "DELETE" | "EXPORT" | "PERMISSION_CHANGE"
|
|
>();
|
|
});
|
|
});
|
|
|
|
describe("AuditFrom", () => {
|
|
it("requires ipTruncated and userAgent", () => {
|
|
expectTypeOf<AuditFrom>().toEqualTypeOf<{ ipTruncated: string; userAgent: string }>();
|
|
});
|
|
});
|
|
|
|
describe("AuditEntry", () => {
|
|
it("requires the WHO/WHAT/WHEN/SCOPE/FROM/PII/OUTCOME fields", () => {
|
|
const entry: AuditEntry = {
|
|
actorId: "user_1",
|
|
actorType: "user",
|
|
actorRoles: ["admin"],
|
|
action: "VIEW",
|
|
resource: { type: "articles" },
|
|
at: new Date(),
|
|
scope: { feature: "blog", environment: "test", tenant: "default" },
|
|
from: { ipTruncated: "10.0.0.0", userAgent: "test" },
|
|
containsPii: false,
|
|
outcome: "success",
|
|
};
|
|
expectTypeOf(entry).toMatchTypeOf<AuditEntry>();
|
|
});
|
|
|
|
it("makes optional fields actually optional", () => {
|
|
type Entry = AuditEntry;
|
|
type OptionalKeys = "changedFields" | "reason" | "correlationId" | "requestId" | "piiCategories" | "errorCode";
|
|
expectTypeOf<Pick<Entry, OptionalKeys>>().toEqualTypeOf<Partial<Pick<Entry, OptionalKeys>>>();
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test → FAIL**
|
|
|
|
```bash
|
|
pnpm --filter @repo/core-shared test audit-entry.test
|
|
```
|
|
Expected: FAIL — module not found.
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
Create `packages/core-shared/src/audit/audit-entry.ts`:
|
|
|
|
```ts
|
|
/**
|
|
* Closed enum of audited actions per DPA. New action types require an
|
|
* explicit type bump — compliance auditors sample by enum value.
|
|
*/
|
|
export type AuditAction =
|
|
| "VIEW"
|
|
| "CREATE"
|
|
| "UPDATE"
|
|
| "DELETE"
|
|
| "EXPORT"
|
|
| "PERMISSION_CHANGE";
|
|
|
|
/**
|
|
* `from_where` fragment per DPA. IP truncated to /24 (IPv4) or /48 (IPv6)
|
|
* before storage; use `truncateIp(rawIp)` to enforce. For non-HTTP contexts,
|
|
* sentinels are conventional: `{ ipTruncated: "system", userAgent: "background-job" }`.
|
|
*/
|
|
export type AuditFrom = {
|
|
ipTruncated: string;
|
|
userAgent: string;
|
|
};
|
|
|
|
/**
|
|
* Universal audit entry. By construction, this type has NO `payload`/`body`/
|
|
* `oldValue`/`newValue` fields — the DPA "what NOT to log" exclusion list is
|
|
* enforced by the type itself. UPDATE actions capture field NAMES only
|
|
* (`changedFields`); per-collection value capture is a separate API (out of
|
|
* scope for v1).
|
|
*/
|
|
export type AuditEntry = {
|
|
// WHO
|
|
/** User id, or "system"/"service-{name}" for non-user actors. NEVER email or name (R36). */
|
|
actorId: string;
|
|
actorType: "user" | "system" | "service";
|
|
/** Snapshot of actor's roles AT TIME OF ACTION — preserves historical state. */
|
|
actorRoles: string[];
|
|
|
|
// WHAT
|
|
action: AuditAction;
|
|
resource: { type: string; id?: string };
|
|
/** UPDATE only: names of fields that changed (NOT values — PII risk). */
|
|
changedFields?: string[];
|
|
|
|
// WHEN
|
|
/** Server time. Sinks serialize as ISO 8601. */
|
|
at: Date;
|
|
|
|
// SCOPE (where)
|
|
scope: {
|
|
feature: string;
|
|
environment: string;
|
|
/** Required field. Single-tenant projects use "default" as the sentinel. */
|
|
tenant: string;
|
|
};
|
|
|
|
// WHY
|
|
reason?: string;
|
|
/** OTel trace ID. Auto-populated by `TraceIdEnrichingAuditLog` decorator at bind time. */
|
|
correlationId?: string;
|
|
requestId?: string;
|
|
|
|
// FROM (per DPA)
|
|
from: AuditFrom;
|
|
|
|
// PII CLASSIFICATION
|
|
/** Caller MUST declare. Drives downstream retention/access policies. */
|
|
containsPii: boolean;
|
|
/**
|
|
* Free-form list. Conventions (suggested, not enforced): "email", "name",
|
|
* "phone", "address", "ssn", "financial", "health". Free-form because
|
|
* regulatory categories differ by jurisdiction.
|
|
*/
|
|
piiCategories?: string[];
|
|
|
|
// OUTCOME
|
|
outcome: "success" | "denied" | "error";
|
|
errorCode?: string;
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 4: Run test → PASS**
|
|
|
|
```bash
|
|
pnpm --filter @repo/core-shared test audit-entry.test
|
|
```
|
|
Expected: PASS, 3 tests.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/core-shared/src/audit/audit-entry.ts packages/core-shared/src/audit/audit-entry.test.ts
|
|
git commit -m "feat(core-shared): AuditEntry type with closed action enum + required tenant"
|
|
```
|
|
|
|
### Task 1.2: truncateIp helper (TDD)
|
|
|
|
**Files:**
|
|
- Create: `packages/core-shared/src/audit/truncate-ip.ts`
|
|
- Create: `packages/core-shared/src/audit/truncate-ip.test.ts`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
Create `packages/core-shared/src/audit/truncate-ip.test.ts`:
|
|
|
|
```ts
|
|
import { describe, it, expect } from "vitest";
|
|
import { truncateIp } from "./truncate-ip";
|
|
|
|
describe("truncateIp", () => {
|
|
describe("IPv4", () => {
|
|
it("truncates to /24 (zeros the last octet)", () => {
|
|
expect(truncateIp("192.168.1.42")).toBe("192.168.1.0");
|
|
expect(truncateIp("10.0.0.255")).toBe("10.0.0.0");
|
|
expect(truncateIp("8.8.8.8")).toBe("8.8.8.0");
|
|
});
|
|
|
|
it("throws on malformed input", () => {
|
|
expect(() => truncateIp("192.168.1")).toThrow(/malformed IPv4/);
|
|
expect(() => truncateIp("192.168.1.foo")).toThrow(/malformed IPv4/);
|
|
expect(() => truncateIp("a.b.c.d")).toThrow(/malformed IPv4/);
|
|
});
|
|
|
|
it("throws on empty string", () => {
|
|
expect(() => truncateIp("")).toThrow(/malformed IPv4/);
|
|
});
|
|
});
|
|
|
|
describe("IPv6", () => {
|
|
it("truncates to /48 (keeps first 3 hextets)", () => {
|
|
expect(truncateIp("2001:0db8:1234:5678:abcd:ef00:1234:5678")).toBe("2001:0db8:1234::");
|
|
expect(truncateIp("2001:0db8:abcd:1234::")).toBe("2001:0db8:abcd::");
|
|
});
|
|
|
|
it("lowercases hextets", () => {
|
|
expect(truncateIp("2001:0DB8:ABCD:5678::")).toBe("2001:0db8:abcd::");
|
|
});
|
|
|
|
it("throws on too-few hextets", () => {
|
|
expect(() => truncateIp("2001:0db8")).toThrow(/malformed IPv6/);
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run → FAIL**
|
|
|
|
```bash
|
|
pnpm --filter @repo/core-shared test truncate-ip.test
|
|
```
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
Create `packages/core-shared/src/audit/truncate-ip.ts`:
|
|
|
|
```ts
|
|
/**
|
|
* Truncates an IP address per DPA:
|
|
* IPv4 → /24 ("192.168.1.42" → "192.168.1.0")
|
|
* IPv6 → /48 ("2001:0db8:1234:5678:..." → "2001:0db8:1234::")
|
|
*
|
|
* Throws on malformed input rather than silently returning the raw value —
|
|
* compliance regimes prefer hard failures over partial scrubbing.
|
|
*/
|
|
export function truncateIp(raw: string): string {
|
|
if (raw.includes(":")) {
|
|
// IPv6: keep first 3 hextets (48 bits)
|
|
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]}::`;
|
|
}
|
|
// IPv4: keep first 3 octets (24 bits)
|
|
const parts = raw.split(".");
|
|
if (
|
|
parts.length !== 4 ||
|
|
parts.some((p) => p === "" || isNaN(Number(p)) || !/^\d+$/.test(p))
|
|
) {
|
|
throw new Error(`truncateIp: malformed IPv4 address "${raw}"`);
|
|
}
|
|
return `${parts[0]}.${parts[1]}.${parts[2]}.0`;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run → PASS**
|
|
|
|
```bash
|
|
pnpm --filter @repo/core-shared test truncate-ip.test
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/core-shared/src/audit/truncate-ip.ts packages/core-shared/src/audit/truncate-ip.test.ts
|
|
git commit -m "feat(core-shared): truncateIp helper (/24 IPv4, /48 IPv6) per DPA"
|
|
```
|
|
|
|
### Task 1.3: AuditLogProtocol + barrel + subpath export
|
|
|
|
**Files:**
|
|
- Modify: `packages/core-shared/src/di/bind-protocols.ts`
|
|
- Create: `packages/core-shared/src/audit/index.ts`
|
|
- Modify: `packages/core-shared/src/index.ts`
|
|
- Modify: `packages/core-shared/package.json`
|
|
|
|
- [ ] **Step 1: Add AuditLogProtocol to bind-protocols.ts**
|
|
|
|
Append to `packages/core-shared/src/di/bind-protocols.ts`:
|
|
|
|
```ts
|
|
import type { AuditEntry } from "../audit/audit-entry";
|
|
|
|
/**
|
|
* Minimal audit-log protocol surface. `IAuditLog` (in optional `@repo/core-audit`)
|
|
* extends this — typechecks fail if narrowed below. Feature binders that
|
|
* receive `ctx.auditLog` see only this protocol type.
|
|
*
|
|
* `eraseSubject` is NOT on the protocol — it's a privileged op exposed only
|
|
* on the full `IAuditLog` interface in the optional package.
|
|
*/
|
|
export type AuditLogProtocol = {
|
|
record(entry: AuditEntry): Promise<void>;
|
|
};
|
|
```
|
|
|
|
(The import is OK because `audit/audit-entry.ts` is a sibling file in the same package.)
|
|
|
|
- [ ] **Step 2: Create audit barrel**
|
|
|
|
Create `packages/core-shared/src/audit/index.ts`:
|
|
|
|
```ts
|
|
export type { AuditEntry, AuditAction, AuditFrom } from "./audit-entry";
|
|
export { truncateIp } from "./truncate-ip";
|
|
```
|
|
|
|
- [ ] **Step 3: Add to core-shared root barrel**
|
|
|
|
Modify `packages/core-shared/src/index.ts` to add a re-export. Find existing `export * from "./..."` lines and add:
|
|
|
|
```ts
|
|
export * from "./audit";
|
|
```
|
|
|
|
- [ ] **Step 4: Add subpath export**
|
|
|
|
In `packages/core-shared/package.json`, add to `exports` (alphabetically among existing entries):
|
|
|
|
```json
|
|
"./audit": "./src/audit/index.ts",
|
|
```
|
|
|
|
- [ ] **Step 5: Verify**
|
|
|
|
```bash
|
|
pnpm --filter @repo/core-shared typecheck
|
|
pnpm --filter @repo/core-shared test
|
|
```
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add packages/core-shared/src/di/bind-protocols.ts \
|
|
packages/core-shared/src/audit/index.ts \
|
|
packages/core-shared/src/index.ts \
|
|
packages/core-shared/package.json
|
|
git commit -m "feat(core-shared): AuditLogProtocol + ./audit subpath export"
|
|
```
|
|
|
|
### Task 1.4: BindContext.auditLog? field (5th generic)
|
|
|
|
**Files:**
|
|
- Modify: `packages/core-shared/src/di/bind-context.ts`
|
|
|
|
- [ ] **Step 1: Add 5th generic + field**
|
|
|
|
Read the current `BindContext` declaration. Modify to add `Audit` as the 5th generic and `auditLog?: Audit` as the new field:
|
|
|
|
```ts
|
|
import type {
|
|
EventBusProtocol,
|
|
RealtimeBroadcasterProtocol,
|
|
RealtimeRegistryProtocol,
|
|
MetricsProtocol,
|
|
AuditLogProtocol, // <- new
|
|
} from "./bind-protocols";
|
|
|
|
// BindContextBase unchanged
|
|
|
|
export type BindContext<
|
|
Bus extends EventBusProtocol = EventBusProtocol,
|
|
Realtime extends RealtimeBroadcasterProtocol = RealtimeBroadcasterProtocol,
|
|
RealtimeReg extends RealtimeRegistryProtocol = RealtimeRegistryProtocol,
|
|
Metrics extends MetricsProtocol = MetricsProtocol,
|
|
Audit extends AuditLogProtocol = AuditLogProtocol, // <- new
|
|
> = BindContextBase & {
|
|
bus?: Bus;
|
|
queue?: IJobQueue;
|
|
realtime?: Realtime;
|
|
realtimeRegistry?: RealtimeReg;
|
|
metrics?: Metrics;
|
|
auditLog?: Audit; // <- new
|
|
};
|
|
|
|
export type BindProductionContext<
|
|
Bus extends EventBusProtocol = EventBusProtocol,
|
|
Realtime extends RealtimeBroadcasterProtocol = RealtimeBroadcasterProtocol,
|
|
RealtimeReg extends RealtimeRegistryProtocol = RealtimeRegistryProtocol,
|
|
Metrics extends MetricsProtocol = MetricsProtocol,
|
|
Audit extends AuditLogProtocol = AuditLogProtocol, // <- new
|
|
> = BindContext<Bus, Realtime, RealtimeReg, Metrics, Audit> & {
|
|
config: SanitizedConfig;
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 2: Verify backward compat**
|
|
|
|
```bash
|
|
pnpm typecheck
|
|
```
|
|
Expected: clean. Existing 4-generic callers still resolve because the 5th generic has a default.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add packages/core-shared/src/di/bind-context.ts
|
|
git commit -m "feat(core-shared): BindContext.auditLog? field (5th generic)"
|
|
```
|
|
|
|
### Task 1.5: Phase 1 verification gate
|
|
|
|
- [ ] **Step 1: Run all gates**
|
|
|
|
```bash
|
|
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries
|
|
```
|
|
Expected: all green. No new lint warnings.
|
|
|
|
(No commit; verification gate only.)
|
|
|
|
---
|
|
|
|
## Phase 2 — @repo/core-audit package with impls
|
|
|
|
**Goal:** Ship the optional package with 4 impls (Noop, Payload, StdoutJson, MultiSink) + `RecordingAuditLog` in core-testing + `bindAudit` binder. Append-only Payload collection definition.
|
|
|
|
**Files touched (new package scaffolding):**
|
|
|
|
- Create: `packages/core-audit/{AGENTS.md, eslint.config.js, package.json, tsconfig.json, turbo.json, vitest.config.ts}`
|
|
- Create: `packages/core-audit/src/{index.ts, audit-log.interface.ts, audit-logs-collection.ts}`
|
|
- Create: `packages/core-audit/src/{noop-audit-log.ts, stdout-json-audit-log.ts, payload-audit-log.ts, multi-sink-audit-log.ts}` (+ tests each)
|
|
- Create: `packages/core-audit/src/di/{bind-audit.ts, symbols.ts}` (+ bind-audit test)
|
|
- Create: `packages/core-testing/src/instrumentation/recording-audit-log.ts` (+ test)
|
|
- Modify: `packages/core-testing/src/instrumentation/index.ts` (re-export)
|
|
- Modify: `apps/web-next/next.config.mjs` (`transpilePackages`)
|
|
- Modify: `pnpm-lock.yaml` (after `pnpm install`)
|
|
|
|
### Task 2.1: Scaffold the package skeleton
|
|
|
|
**Files:** All `packages/core-audit/` top-level files.
|
|
|
|
- [ ] **Step 1: Inspect an existing optional package for reference**
|
|
|
|
```bash
|
|
ls turbo/generators/templates/core-package/events/
|
|
cat turbo/generators/templates/core-package/events/package.json.hbs
|
|
cat turbo/generators/templates/core-package/events/tsconfig.json.hbs
|
|
cat turbo/generators/templates/core-package/events/turbo.json.hbs
|
|
cat turbo/generators/templates/core-package/events/vitest.config.ts.hbs
|
|
cat turbo/generators/templates/core-package/events/eslint.config.js.hbs
|
|
```
|
|
|
|
These are the canonical shapes. Mirror them for core-audit.
|
|
|
|
- [ ] **Step 2: Create the directory + scaffolding**
|
|
|
|
```bash
|
|
mkdir -p packages/core-audit/src/{di,integrations/api,hooks}
|
|
```
|
|
|
|
- [ ] **Step 3: package.json**
|
|
|
|
Create `packages/core-audit/package.json`:
|
|
|
|
```json
|
|
{
|
|
"name": "@repo/core-audit",
|
|
"version": "0.0.1",
|
|
"private": true,
|
|
"type": "module",
|
|
"exports": {
|
|
".": "./src/index.ts",
|
|
"./collection": "./src/audit-logs-collection.ts",
|
|
"./di": "./src/di/bind-audit.ts",
|
|
"./hooks": "./src/hooks/index.ts",
|
|
"./api": "./src/integrations/api/router.ts"
|
|
},
|
|
"scripts": {
|
|
"build": "tsc --noEmit",
|
|
"lint": "eslint .",
|
|
"typecheck": "tsc --noEmit",
|
|
"test": "vitest run"
|
|
},
|
|
"dependencies": {
|
|
"@repo/core-shared": "workspace:*",
|
|
"@trpc/server": "^11.0.0",
|
|
"zod": "^3.23.0"
|
|
},
|
|
"peerDependencies": {
|
|
"payload": "^3.0.0"
|
|
},
|
|
"peerDependenciesMeta": {
|
|
"payload": { "optional": true }
|
|
},
|
|
"devDependencies": {
|
|
"@repo/core-eslint": "workspace:*",
|
|
"@repo/core-testing": "workspace:*",
|
|
"@repo/core-typescript": "workspace:*",
|
|
"inversify": "^6.2.0",
|
|
"payload": "^3.14.0",
|
|
"reflect-metadata": "^0.2.2",
|
|
"typescript": "^5.8.0",
|
|
"vitest": "^3.0.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: tsconfig.json**
|
|
|
|
Create `packages/core-audit/tsconfig.json`:
|
|
|
|
```json
|
|
{
|
|
"extends": "@repo/core-typescript/base.json",
|
|
"compilerOptions": {
|
|
"rootDir": ".",
|
|
"baseUrl": ".",
|
|
"paths": {
|
|
"@/*": ["./src/*"]
|
|
}
|
|
},
|
|
"include": ["**/*.ts"],
|
|
"exclude": ["node_modules", "dist"]
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: turbo.json**
|
|
|
|
Create `packages/core-audit/turbo.json`:
|
|
|
|
```json
|
|
{
|
|
"extends": ["//"],
|
|
"tags": ["core"]
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: vitest.config.ts**
|
|
|
|
Create `packages/core-audit/vitest.config.ts`:
|
|
|
|
```ts
|
|
import { defineConfig } from "vitest/config";
|
|
import path from "node:path";
|
|
|
|
export default defineConfig({
|
|
resolve: {
|
|
alias: { "@": path.resolve(__dirname, "./src") },
|
|
},
|
|
test: {
|
|
setupFiles: ["@repo/core-testing/setup/no-instrumentation"],
|
|
},
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 7: eslint.config.js**
|
|
|
|
Create `packages/core-audit/eslint.config.js`:
|
|
|
|
```js
|
|
import base from "@repo/core-eslint/base";
|
|
|
|
export default [...base];
|
|
```
|
|
|
|
- [ ] **Step 8: AGENTS.md**
|
|
|
|
Create `packages/core-audit/AGENTS.md`:
|
|
|
|
```markdown
|
|
# @repo/core-audit
|
|
|
|
Optional core package providing DPA-compliant audit logging. Scaffold via `pnpm turbo gen core-package audit`.
|
|
|
|
## Structure
|
|
|
|
```
|
|
src/
|
|
audit-log.interface.ts # IAuditLog extends AuditLogProtocol
|
|
audit-logs-collection.ts # Payload collection (append-only)
|
|
noop-audit-log.ts # NoopAuditLog
|
|
payload-audit-log.ts # PayloadAuditLog (local cache impl)
|
|
stdout-json-audit-log.ts # StdoutJsonAuditLog (log-shipper sink)
|
|
multi-sink-audit-log.ts # MultiSinkAuditLog (fan-out wrapper)
|
|
trace-id-enriching-audit-log.ts # OTel correlation decorator
|
|
pseudonymize.ts # sha256-with-salt for GDPR pseudonymization
|
|
di/bind-audit.ts # bindAudit binder
|
|
integrations/api/router.ts # admin tRPC procedure
|
|
hooks/ # Payload hook factories
|
|
```
|
|
|
|
## Compliance posture
|
|
|
|
- `AuditEntry` type (in `@repo/core-shared/audit`) has no `payload`/`body`/`oldValue`/`newValue` fields — type system enforces DPA "what NOT to log".
|
|
- Append-only Payload collection (`update: () => false`); erasure uses `overrideAccess: true` for the privileged path.
|
|
- `AUDIT_PSEUDONYM_SALT` env REQUIRED in production. Validated at bind time.
|
|
|
|
See `docs/guides/audit-and-compliance.md` for the full guide.
|
|
```
|
|
|
|
- [ ] **Step 9: Install + verify**
|
|
|
|
```bash
|
|
pnpm install
|
|
pnpm --filter @repo/core-audit typecheck
|
|
```
|
|
Expected: no errors. The package exists but has no source files yet.
|
|
|
|
- [ ] **Step 10: Add to transpilePackages**
|
|
|
|
Modify `apps/web-next/next.config.mjs` — find the `transpilePackages` array and add `"@repo/core-audit"` (alphabetically among existing entries).
|
|
|
|
- [ ] **Step 11: Commit**
|
|
|
|
```bash
|
|
git add packages/core-audit/ apps/web-next/next.config.mjs pnpm-lock.yaml
|
|
git commit -m "feat(core-audit): scaffold optional package (no impls yet)"
|
|
```
|
|
|
|
### Task 2.2: IAuditLog interface + AUDIT_SYMBOLS
|
|
|
|
**Files:**
|
|
- Create: `packages/core-audit/src/audit-log.interface.ts`
|
|
- Create: `packages/core-audit/src/di/symbols.ts`
|
|
|
|
- [ ] **Step 1: Create the interface**
|
|
|
|
Create `packages/core-audit/src/audit-log.interface.ts`:
|
|
|
|
```ts
|
|
import type { AuditLogProtocol, AuditEntry } from "@repo/core-shared/audit";
|
|
|
|
/**
|
|
* Full audit log interface. Extends the minimal `AuditLogProtocol` from
|
|
* core-shared with the privileged `eraseSubject` op for GDPR erasure.
|
|
*
|
|
* Feature binders that receive `ctx.auditLog` see only `AuditLogProtocol`
|
|
* (record). Admin-path code that needs erasure imports this full interface.
|
|
*
|
|
* The `extends` link forces typecheck failure if either side narrows below
|
|
* the protocol surface — same safety net as IEventBus, IRealtimeBroadcaster,
|
|
* IRealtimeHandlerRegistry, IMetrics.
|
|
*/
|
|
export interface IAuditLog extends AuditLogProtocol {
|
|
// record(entry: AuditEntry): Promise<void> — inherited from protocol
|
|
eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void>;
|
|
}
|
|
|
|
// Re-export AuditEntry for convenience (so consumers don't always need
|
|
// to dual-import from @repo/core-shared/audit).
|
|
export type { AuditEntry };
|
|
```
|
|
|
|
- [ ] **Step 2: Create DI symbols**
|
|
|
|
Create `packages/core-audit/src/di/symbols.ts`:
|
|
|
|
```ts
|
|
export const AUDIT_SYMBOLS = {
|
|
IAuditLog: Symbol.for("core-audit:IAuditLog"),
|
|
} as const;
|
|
```
|
|
|
|
- [ ] **Step 3: Verify**
|
|
|
|
```bash
|
|
pnpm --filter @repo/core-audit typecheck
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add packages/core-audit/src/audit-log.interface.ts packages/core-audit/src/di/symbols.ts
|
|
git commit -m "feat(core-audit): IAuditLog interface + AUDIT_SYMBOLS"
|
|
```
|
|
|
|
### Task 2.3: NoopAuditLog (TDD)
|
|
|
|
**Files:**
|
|
- Create: `packages/core-audit/src/noop-audit-log.ts` (+ test)
|
|
|
|
- [ ] **Step 1: Write failing test**
|
|
|
|
Create `packages/core-audit/src/noop-audit-log.test.ts`:
|
|
|
|
```ts
|
|
import { describe, it, expect } from "vitest";
|
|
import { NoopAuditLog } from "./noop-audit-log";
|
|
import type { AuditEntry } from "@repo/core-shared/audit";
|
|
|
|
describe("NoopAuditLog", () => {
|
|
const sample: AuditEntry = {
|
|
actorId: "user_1",
|
|
actorType: "user",
|
|
actorRoles: [],
|
|
action: "VIEW",
|
|
resource: { type: "articles", id: "1" },
|
|
at: new Date(),
|
|
scope: { feature: "blog", environment: "test", tenant: "default" },
|
|
from: { ipTruncated: "10.0.0.0", userAgent: "test" },
|
|
containsPii: false,
|
|
outcome: "success",
|
|
};
|
|
|
|
it("record() is a no-op that does not throw", async () => {
|
|
const log = new NoopAuditLog();
|
|
await expect(log.record(sample)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("eraseSubject() is a no-op that does not throw", async () => {
|
|
const log = new NoopAuditLog();
|
|
await expect(log.eraseSubject("user_1", "pseudonymize")).resolves.toBeUndefined();
|
|
await expect(log.eraseSubject("user_1", "delete")).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run → FAIL**
|
|
|
|
```bash
|
|
pnpm --filter @repo/core-audit test noop-audit-log.test
|
|
```
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
Create `packages/core-audit/src/noop-audit-log.ts`:
|
|
|
|
```ts
|
|
import type { AuditEntry } from "@repo/core-shared/audit";
|
|
import type { IAuditLog } from "./audit-log.interface";
|
|
|
|
export class NoopAuditLog implements IAuditLog {
|
|
async record(_entry: AuditEntry): Promise<void> {
|
|
// intentional no-op
|
|
}
|
|
async eraseSubject(_actorId: string, _mode: "pseudonymize" | "delete"): Promise<void> {
|
|
// intentional no-op
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run → PASS**
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/core-audit/src/noop-audit-log.ts packages/core-audit/src/noop-audit-log.test.ts
|
|
git commit -m "feat(core-audit): NoopAuditLog impl"
|
|
```
|
|
|
|
### Task 2.4: StdoutJsonAuditLog (TDD)
|
|
|
|
**Files:**
|
|
- Create: `packages/core-audit/src/stdout-json-audit-log.ts` (+ test)
|
|
|
|
- [ ] **Step 1: Write failing test**
|
|
|
|
Create `packages/core-audit/src/stdout-json-audit-log.test.ts`:
|
|
|
|
```ts
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { StdoutJsonAuditLog } from "./stdout-json-audit-log";
|
|
import type { AuditEntry } from "@repo/core-shared/audit";
|
|
|
|
const sample: AuditEntry = {
|
|
actorId: "user_1",
|
|
actorType: "user",
|
|
actorRoles: ["admin"],
|
|
action: "CREATE",
|
|
resource: { type: "articles", id: "abc" },
|
|
at: new Date("2026-05-11T10:00:00.000Z"),
|
|
scope: { feature: "blog", environment: "production", tenant: "default" },
|
|
from: { ipTruncated: "10.0.0.0", userAgent: "Mozilla/5.0" },
|
|
containsPii: false,
|
|
outcome: "success",
|
|
};
|
|
|
|
describe("StdoutJsonAuditLog", () => {
|
|
let writeSpy: ReturnType<typeof vi.spyOn>;
|
|
beforeEach(() => {
|
|
writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
});
|
|
|
|
it("record() writes one JSON line per entry to stdout", async () => {
|
|
const log = new StdoutJsonAuditLog();
|
|
await log.record(sample);
|
|
expect(writeSpy).toHaveBeenCalledOnce();
|
|
const written = writeSpy.mock.calls[0]![0] as string;
|
|
expect(written.endsWith("\n")).toBe(true);
|
|
const parsed = JSON.parse(written.trimEnd());
|
|
expect(parsed._type).toBe("audit");
|
|
expect(parsed.actorId).toBe("user_1");
|
|
expect(parsed.action).toBe("CREATE");
|
|
expect(parsed.at).toBe("2026-05-11T10:00:00.000Z"); // ISO 8601 serialization
|
|
});
|
|
|
|
it("eraseSubject() emits a tombstone with mode + actorId", async () => {
|
|
const log = new StdoutJsonAuditLog();
|
|
await log.eraseSubject("user_1", "pseudonymize");
|
|
expect(writeSpy).toHaveBeenCalledOnce();
|
|
const written = writeSpy.mock.calls[0]![0] as string;
|
|
const parsed = JSON.parse(written.trimEnd());
|
|
expect(parsed._type).toBe("audit-erasure");
|
|
expect(parsed.actorId).toBe("user_1");
|
|
expect(parsed.mode).toBe("pseudonymize");
|
|
expect(typeof parsed.at).toBe("string"); // ISO 8601 timestamp
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run → FAIL**
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
Create `packages/core-audit/src/stdout-json-audit-log.ts`:
|
|
|
|
```ts
|
|
import type { AuditEntry } from "@repo/core-shared/audit";
|
|
import type { IAuditLog } from "./audit-log.interface";
|
|
|
|
/**
|
|
* Writes one structured JSON line per audit entry to stdout. A log shipper
|
|
* (Vector, Fluent Bit) picks these up and forwards to the centralized
|
|
* aggregator (Grafana Cloud, Datadog, Loki EU, etc.).
|
|
*
|
|
* Lines include a `_type` discriminator so the shipper can route:
|
|
* "audit" → audit entry
|
|
* "audit-erasure" → GDPR erasure tombstone
|
|
*
|
|
* `eraseSubject` is best-effort: past stdout lines can't be retroactively
|
|
* removed. The tombstone informs the downstream aggregator to filter/delete.
|
|
*/
|
|
export class StdoutJsonAuditLog implements IAuditLog {
|
|
async record(entry: AuditEntry): Promise<void> {
|
|
const serialized = JSON.stringify({
|
|
_type: "audit",
|
|
...entry,
|
|
at: entry.at.toISOString(),
|
|
});
|
|
process.stdout.write(serialized + "\n");
|
|
}
|
|
|
|
async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
|
|
const tombstone = {
|
|
_type: "audit-erasure",
|
|
actorId,
|
|
mode,
|
|
at: new Date().toISOString(),
|
|
};
|
|
process.stdout.write(JSON.stringify(tombstone) + "\n");
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run → PASS**
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/core-audit/src/stdout-json-audit-log.ts packages/core-audit/src/stdout-json-audit-log.test.ts
|
|
git commit -m "feat(core-audit): StdoutJsonAuditLog impl with audit + audit-erasure markers"
|
|
```
|
|
|
|
### Task 2.5: auditLogs Payload collection
|
|
|
|
**Files:**
|
|
- Create: `packages/core-audit/src/audit-logs-collection.ts` (+ test)
|
|
|
|
- [ ] **Step 1: Write the collection-shape test**
|
|
|
|
Create `packages/core-audit/src/audit-logs-collection.test.ts`:
|
|
|
|
```ts
|
|
import { describe, it, expect } from "vitest";
|
|
import { auditLogsCollection } from "./audit-logs-collection";
|
|
|
|
describe("auditLogsCollection", () => {
|
|
it("uses slug 'audit-logs'", () => {
|
|
expect(auditLogsCollection.slug).toBe("audit-logs");
|
|
});
|
|
|
|
it("is append-only (update: () => false)", () => {
|
|
const access = auditLogsCollection.access as Record<string, () => boolean>;
|
|
expect(access.update()).toBe(false);
|
|
});
|
|
|
|
it("has the required fields", () => {
|
|
const fieldNames = (auditLogsCollection.fields as Array<{ name: string }>).map((f) => f.name);
|
|
// WHO
|
|
expect(fieldNames).toContain("actorId");
|
|
expect(fieldNames).toContain("actorType");
|
|
expect(fieldNames).toContain("actorRoles");
|
|
// WHAT
|
|
expect(fieldNames).toContain("action");
|
|
expect(fieldNames).toContain("resourceType");
|
|
expect(fieldNames).toContain("resourceId");
|
|
expect(fieldNames).toContain("changedFields");
|
|
// SCOPE
|
|
expect(fieldNames).toContain("scopeFeature");
|
|
expect(fieldNames).toContain("scopeEnvironment");
|
|
expect(fieldNames).toContain("scopeTenant");
|
|
// WHY
|
|
expect(fieldNames).toContain("reason");
|
|
expect(fieldNames).toContain("correlationId");
|
|
expect(fieldNames).toContain("requestId");
|
|
// FROM
|
|
expect(fieldNames).toContain("ipTruncated");
|
|
expect(fieldNames).toContain("userAgent");
|
|
// PII
|
|
expect(fieldNames).toContain("containsPii");
|
|
expect(fieldNames).toContain("piiCategories");
|
|
// OUTCOME
|
|
expect(fieldNames).toContain("outcome");
|
|
expect(fieldNames).toContain("errorCode");
|
|
});
|
|
|
|
it("enables timestamps so createdAt maps to AuditEntry.at", () => {
|
|
expect(auditLogsCollection.timestamps).toBe(true);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run → FAIL**
|
|
|
|
```bash
|
|
pnpm --filter @repo/core-audit test audit-logs-collection.test
|
|
```
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
Create `packages/core-audit/src/audit-logs-collection.ts`:
|
|
|
|
```ts
|
|
import type { CollectionConfig } from "payload";
|
|
|
|
/**
|
|
* Append-only Payload collection for audit entries. Mounted by core-cms
|
|
* when this package is scaffolded (manual wiring step printed by generator).
|
|
*
|
|
* Access rules:
|
|
* - read: admins only
|
|
* - create: any authenticated context (filtered upstream by PayloadAuditLog)
|
|
* - update: NEVER (compliance requires append-only)
|
|
* - delete: admins only (used by the GDPR erasure path with overrideAccess)
|
|
*
|
|
* The `update: () => false` rule is the compliance backbone. The erasure
|
|
* path uses `overrideAccess: true` to bypass for pseudonymization — that's
|
|
* Payload's documented escape hatch for privileged operations.
|
|
*/
|
|
export const auditLogsCollection: CollectionConfig = {
|
|
slug: "audit-logs",
|
|
access: {
|
|
read: ({ req }) => {
|
|
const user = req.user as { roles?: string[] } | null | undefined;
|
|
return Array.isArray(user?.roles) && user.roles.includes("admin");
|
|
},
|
|
create: () => true,
|
|
update: () => false,
|
|
delete: ({ req }) => {
|
|
const user = req.user as { roles?: string[] } | null | undefined;
|
|
return Array.isArray(user?.roles) && user.roles.includes("admin");
|
|
},
|
|
},
|
|
timestamps: true,
|
|
fields: [
|
|
// WHO
|
|
{ name: "actorId", type: "text", required: true, index: true },
|
|
{
|
|
name: "actorType",
|
|
type: "select",
|
|
options: ["user", "system", "service"],
|
|
required: true,
|
|
},
|
|
{ name: "actorRoles", type: "json", required: true },
|
|
|
|
// WHAT
|
|
{
|
|
name: "action",
|
|
type: "select",
|
|
options: ["VIEW", "CREATE", "UPDATE", "DELETE", "EXPORT", "PERMISSION_CHANGE"],
|
|
required: true,
|
|
index: true,
|
|
},
|
|
{ name: "resourceType", type: "text", required: true, index: true },
|
|
{ name: "resourceId", type: "text" },
|
|
{ name: "changedFields", type: "json" },
|
|
|
|
// SCOPE
|
|
{ name: "scopeFeature", type: "text", required: true, index: true },
|
|
{ name: "scopeEnvironment", type: "text", required: true },
|
|
{ name: "scopeTenant", type: "text", required: true, index: true },
|
|
|
|
// WHY
|
|
{ name: "reason", type: "text" },
|
|
{ name: "correlationId", type: "text", index: true },
|
|
{ name: "requestId", type: "text" },
|
|
|
|
// FROM
|
|
{ name: "ipTruncated", type: "text", required: true },
|
|
{ name: "userAgent", type: "text", required: true },
|
|
|
|
// PII
|
|
{ name: "containsPii", type: "checkbox", required: true },
|
|
{ name: "piiCategories", type: "json" },
|
|
|
|
// OUTCOME
|
|
{
|
|
name: "outcome",
|
|
type: "select",
|
|
options: ["success", "denied", "error"],
|
|
required: true,
|
|
},
|
|
{ name: "errorCode", type: "text" },
|
|
],
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 4: Run → PASS**
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/core-audit/src/audit-logs-collection.ts packages/core-audit/src/audit-logs-collection.test.ts
|
|
git commit -m "feat(core-audit): append-only auditLogs Payload collection"
|
|
```
|
|
|
|
### Task 2.6: PayloadAuditLog (TDD, record only — eraseSubject in Phase 3)
|
|
|
|
**Files:**
|
|
- Create: `packages/core-audit/src/payload-audit-log.ts` (+ test)
|
|
|
|
- [ ] **Step 1: Write failing test**
|
|
|
|
Create `packages/core-audit/src/payload-audit-log.test.ts`:
|
|
|
|
```ts
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { PayloadAuditLog } from "./payload-audit-log";
|
|
import type { AuditEntry } from "@repo/core-shared/audit";
|
|
|
|
const sample: AuditEntry = {
|
|
actorId: "user_1",
|
|
actorType: "user",
|
|
actorRoles: ["admin"],
|
|
action: "UPDATE",
|
|
resource: { type: "articles", id: "abc" },
|
|
changedFields: ["title", "body"],
|
|
at: new Date("2026-05-11T10:00:00.000Z"),
|
|
scope: { feature: "blog", environment: "production", tenant: "default" },
|
|
from: { ipTruncated: "10.0.0.0", userAgent: "Mozilla/5.0" },
|
|
containsPii: false,
|
|
outcome: "success",
|
|
};
|
|
|
|
describe("PayloadAuditLog.record", () => {
|
|
it("maps AuditEntry → flat collection doc + calls payload.create", async () => {
|
|
const mockCreate = vi.fn().mockResolvedValue({ id: "doc_1" });
|
|
const mockGetPayload = vi.fn().mockResolvedValue({ create: mockCreate });
|
|
const log = new PayloadAuditLog({} as never, mockGetPayload);
|
|
|
|
await log.record(sample);
|
|
|
|
expect(mockCreate).toHaveBeenCalledOnce();
|
|
const call = mockCreate.mock.calls[0]![0] as { collection: string; data: Record<string, unknown> };
|
|
expect(call.collection).toBe("audit-logs");
|
|
expect(call.data.actorId).toBe("user_1");
|
|
expect(call.data.action).toBe("UPDATE");
|
|
expect(call.data.resourceType).toBe("articles");
|
|
expect(call.data.resourceId).toBe("abc");
|
|
expect(call.data.changedFields).toEqual(["title", "body"]);
|
|
expect(call.data.scopeFeature).toBe("blog");
|
|
expect(call.data.scopeTenant).toBe("default");
|
|
expect(call.data.ipTruncated).toBe("10.0.0.0");
|
|
expect(call.data.containsPii).toBe(false);
|
|
expect(call.data.outcome).toBe("success");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run → FAIL**
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
Create `packages/core-audit/src/payload-audit-log.ts`:
|
|
|
|
```ts
|
|
import type { SanitizedConfig } from "payload";
|
|
import type { AuditEntry } from "@repo/core-shared/audit";
|
|
import type { IAuditLog } from "./audit-log.interface";
|
|
|
|
type GetPayload = (args: { config: SanitizedConfig }) => Promise<{
|
|
create: (args: { collection: string; data: Record<string, unknown> }) => Promise<unknown>;
|
|
find: (args: {
|
|
collection: string;
|
|
where: Record<string, unknown>;
|
|
limit: number;
|
|
overrideAccess: true;
|
|
}) => Promise<{ docs: Array<{ id: string | number }> }>;
|
|
update: (args: {
|
|
collection: string;
|
|
id: string | number;
|
|
data: Record<string, unknown>;
|
|
overrideAccess: true;
|
|
}) => Promise<unknown>;
|
|
delete: (args: {
|
|
collection: string;
|
|
where: Record<string, unknown>;
|
|
overrideAccess: true;
|
|
}) => Promise<unknown>;
|
|
}>;
|
|
|
|
/**
|
|
* Local-cache audit sink: writes entries to the `audit-logs` Payload
|
|
* collection. The collection is append-only by access-rule
|
|
* (`update: () => false`); the eraseSubject path uses `overrideAccess: true`
|
|
* to bypass for the privileged GDPR pseudonymization op.
|
|
*
|
|
* The getPayload param is injectable for tests; production callers pass
|
|
* the real `getPayload` from `payload`.
|
|
*/
|
|
export class PayloadAuditLog implements IAuditLog {
|
|
constructor(
|
|
private readonly config: SanitizedConfig,
|
|
private readonly getPayload: GetPayload,
|
|
) {}
|
|
|
|
async record(entry: AuditEntry): Promise<void> {
|
|
const payload = await this.getPayload({ config: this.config });
|
|
await payload.create({
|
|
collection: "audit-logs",
|
|
data: {
|
|
actorId: entry.actorId,
|
|
actorType: entry.actorType,
|
|
actorRoles: entry.actorRoles,
|
|
action: entry.action,
|
|
resourceType: entry.resource.type,
|
|
resourceId: entry.resource.id ?? null,
|
|
changedFields: entry.changedFields ?? null,
|
|
scopeFeature: entry.scope.feature,
|
|
scopeEnvironment: entry.scope.environment,
|
|
scopeTenant: entry.scope.tenant,
|
|
reason: entry.reason ?? null,
|
|
correlationId: entry.correlationId ?? null,
|
|
requestId: entry.requestId ?? null,
|
|
ipTruncated: entry.from.ipTruncated,
|
|
userAgent: entry.from.userAgent,
|
|
containsPii: entry.containsPii,
|
|
piiCategories: entry.piiCategories ?? null,
|
|
outcome: entry.outcome,
|
|
errorCode: entry.errorCode ?? null,
|
|
},
|
|
});
|
|
}
|
|
|
|
async eraseSubject(_actorId: string, _mode: "pseudonymize" | "delete"): Promise<void> {
|
|
// Implemented in Phase 3.
|
|
throw new Error("PayloadAuditLog.eraseSubject not yet implemented (Phase 3)");
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run → PASS**
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/core-audit/src/payload-audit-log.ts packages/core-audit/src/payload-audit-log.test.ts
|
|
git commit -m "feat(core-audit): PayloadAuditLog.record impl (eraseSubject lands in Phase 3)"
|
|
```
|
|
|
|
### Task 2.7: MultiSinkAuditLog (TDD)
|
|
|
|
**Files:**
|
|
- Create: `packages/core-audit/src/multi-sink-audit-log.ts` (+ test)
|
|
|
|
- [ ] **Step 1: Write failing test**
|
|
|
|
Create `packages/core-audit/src/multi-sink-audit-log.test.ts`:
|
|
|
|
```ts
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { MultiSinkAuditLog } from "./multi-sink-audit-log";
|
|
import { NoopAuditLog } from "./noop-audit-log";
|
|
import type { AuditEntry } from "@repo/core-shared/audit";
|
|
import type { IAuditLog } from "./audit-log.interface";
|
|
|
|
const sample: AuditEntry = {
|
|
actorId: "user_1",
|
|
actorType: "user",
|
|
actorRoles: [],
|
|
action: "VIEW",
|
|
resource: { type: "articles" },
|
|
at: new Date(),
|
|
scope: { feature: "blog", environment: "test", tenant: "default" },
|
|
from: { ipTruncated: "10.0.0.0", userAgent: "test" },
|
|
containsPii: false,
|
|
outcome: "success",
|
|
};
|
|
|
|
function makeRecorder(): IAuditLog & { records: AuditEntry[]; erasures: string[] } {
|
|
const records: AuditEntry[] = [];
|
|
const erasures: string[] = [];
|
|
return {
|
|
records,
|
|
erasures,
|
|
async record(e) { records.push(e); },
|
|
async eraseSubject(actorId) { erasures.push(actorId); },
|
|
};
|
|
}
|
|
|
|
describe("MultiSinkAuditLog", () => {
|
|
it("record() fans out to every sink", async () => {
|
|
const a = makeRecorder();
|
|
const b = makeRecorder();
|
|
const m = new MultiSinkAuditLog([a, b]);
|
|
await m.record(sample);
|
|
expect(a.records).toHaveLength(1);
|
|
expect(b.records).toHaveLength(1);
|
|
});
|
|
|
|
it("settle-all: one sink failing does not skip others", async () => {
|
|
const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
const a: IAuditLog = { record: async () => { throw new Error("a-fail"); }, eraseSubject: async () => {} };
|
|
const b = makeRecorder();
|
|
const m = new MultiSinkAuditLog([a, b]);
|
|
|
|
await m.record(sample);
|
|
|
|
expect(b.records).toHaveLength(1); // b still received the entry
|
|
expect(errSpy).toHaveBeenCalledOnce();
|
|
const written = errSpy.mock.calls[0]![0] as string;
|
|
const parsed = JSON.parse(written.trimEnd());
|
|
expect(parsed._type).toBe("audit-sink-error");
|
|
expect(parsed.error).toContain("a-fail");
|
|
errSpy.mockRestore();
|
|
});
|
|
|
|
it("eraseSubject() fans out to every sink", async () => {
|
|
const a = makeRecorder();
|
|
const b = makeRecorder();
|
|
const m = new MultiSinkAuditLog([a, b]);
|
|
await m.eraseSubject("user_1", "delete");
|
|
expect(a.erasures).toEqual(["user_1"]);
|
|
expect(b.erasures).toEqual(["user_1"]);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run → FAIL**
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
Create `packages/core-audit/src/multi-sink-audit-log.ts`:
|
|
|
|
```ts
|
|
import type { AuditEntry } from "@repo/core-shared/audit";
|
|
import type { IAuditLog } from "./audit-log.interface";
|
|
|
|
/**
|
|
* Fan-out wrapper. Delivers each entry to every inner sink with settle-all
|
|
* semantics — one failing sink doesn't drop the audit entry from others.
|
|
*
|
|
* Failures emit a structured `audit-sink-error` JSON line to stderr.
|
|
* Stderr (not via OTel/Sentry) avoids recursion: if Sentry is one of the
|
|
* sinks failing and we routed the error back through Sentry's reporter,
|
|
* we'd loop. Stderr is consumed by the same log shipper as audit entries
|
|
* themselves, so the operator sees the failure in their aggregator.
|
|
*/
|
|
export class MultiSinkAuditLog implements IAuditLog {
|
|
constructor(private readonly sinks: IAuditLog[]) {}
|
|
|
|
async record(entry: AuditEntry): Promise<void> {
|
|
const results = await Promise.allSettled(this.sinks.map((s) => s.record(entry)));
|
|
for (const r of results) {
|
|
if (r.status === "rejected") {
|
|
this.reportSinkError(r.reason);
|
|
}
|
|
}
|
|
}
|
|
|
|
async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
|
|
const results = await Promise.allSettled(
|
|
this.sinks.map((s) => s.eraseSubject(actorId, mode)),
|
|
);
|
|
for (const r of results) {
|
|
if (r.status === "rejected") {
|
|
this.reportSinkError(r.reason);
|
|
}
|
|
}
|
|
}
|
|
|
|
private reportSinkError(reason: unknown): void {
|
|
const line = JSON.stringify({
|
|
_type: "audit-sink-error",
|
|
error: String(reason),
|
|
at: new Date().toISOString(),
|
|
});
|
|
process.stderr.write(line + "\n");
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run → PASS**
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/core-audit/src/multi-sink-audit-log.ts packages/core-audit/src/multi-sink-audit-log.test.ts
|
|
git commit -m "feat(core-audit): MultiSinkAuditLog fan-out with settle-all + stderr fallback"
|
|
```
|
|
|
|
### Task 2.8: bindAudit binder (TDD)
|
|
|
|
**Files:**
|
|
- Create: `packages/core-audit/src/di/bind-audit.ts` (+ test)
|
|
|
|
- [ ] **Step 1: Write failing test**
|
|
|
|
Create `packages/core-audit/src/di/bind-audit.test.ts`:
|
|
|
|
```ts
|
|
import "reflect-metadata";
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { Container } from "inversify";
|
|
import { bindAudit } from "./bind-audit";
|
|
import { AUDIT_SYMBOLS } from "./symbols";
|
|
import { NoopAuditLog } from "../noop-audit-log";
|
|
import { StdoutJsonAuditLog } from "../stdout-json-audit-log";
|
|
import { PayloadAuditLog } from "../payload-audit-log";
|
|
import { MultiSinkAuditLog } from "../multi-sink-audit-log";
|
|
import type { IAuditLog } from "../audit-log.interface";
|
|
|
|
describe("bindAudit", () => {
|
|
it("defaults to MultiSinkAuditLog([payload, stdout]) when payloadConfig is provided", () => {
|
|
const container = new Container();
|
|
bindAudit(container, { payloadConfig: {} as never });
|
|
const auditLog = container.get<IAuditLog>(AUDIT_SYMBOLS.IAuditLog);
|
|
expect(auditLog).toBeInstanceOf(MultiSinkAuditLog);
|
|
});
|
|
|
|
it("returns StdoutJsonAuditLog alone when payloadConfig omitted + default sinks", () => {
|
|
const container = new Container();
|
|
bindAudit(container, {});
|
|
const auditLog = container.get<IAuditLog>(AUDIT_SYMBOLS.IAuditLog);
|
|
expect(auditLog).toBeInstanceOf(StdoutJsonAuditLog);
|
|
});
|
|
|
|
it("returns NoopAuditLog when sinks=[]", () => {
|
|
const container = new Container();
|
|
bindAudit(container, { sinks: [] });
|
|
const auditLog = container.get<IAuditLog>(AUDIT_SYMBOLS.IAuditLog);
|
|
expect(auditLog).toBeInstanceOf(NoopAuditLog);
|
|
});
|
|
|
|
it("returns PayloadAuditLog when sinks=['payload'] only", () => {
|
|
const container = new Container();
|
|
bindAudit(container, { payloadConfig: {} as never, sinks: ["payload"] });
|
|
const auditLog = container.get<IAuditLog>(AUDIT_SYMBOLS.IAuditLog);
|
|
expect(auditLog).toBeInstanceOf(PayloadAuditLog);
|
|
});
|
|
|
|
it("validates AUDIT_PSEUDONYM_SALT in production", () => {
|
|
const oldEnv = process.env.NODE_ENV;
|
|
const oldSalt = process.env.AUDIT_PSEUDONYM_SALT;
|
|
process.env.NODE_ENV = "production";
|
|
delete process.env.AUDIT_PSEUDONYM_SALT;
|
|
expect(() => bindAudit(new Container(), { sinks: ["stdout"] })).toThrow(
|
|
/AUDIT_PSEUDONYM_SALT/,
|
|
);
|
|
process.env.NODE_ENV = oldEnv;
|
|
if (oldSalt) process.env.AUDIT_PSEUDONYM_SALT = oldSalt;
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run → FAIL**
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
Create `packages/core-audit/src/di/bind-audit.ts`:
|
|
|
|
```ts
|
|
import "reflect-metadata";
|
|
import type { Container } from "inversify";
|
|
import { getPayload, type SanitizedConfig } from "payload";
|
|
import { NoopAuditLog } from "../noop-audit-log";
|
|
import { PayloadAuditLog } from "../payload-audit-log";
|
|
import { StdoutJsonAuditLog } from "../stdout-json-audit-log";
|
|
import { MultiSinkAuditLog } from "../multi-sink-audit-log";
|
|
import type { IAuditLog } from "../audit-log.interface";
|
|
import { AUDIT_SYMBOLS } from "./symbols";
|
|
|
|
export type BindAuditOpts = {
|
|
/** Payload config; required if "payload" is in sinks. */
|
|
payloadConfig?: SanitizedConfig;
|
|
/** Sink selection. Default ["payload", "stdout"]. */
|
|
sinks?: ("payload" | "stdout")[];
|
|
};
|
|
|
|
/**
|
|
* Binds an `IAuditLog` impl to the container under `AUDIT_SYMBOLS.IAuditLog`.
|
|
*
|
|
* Default sink set: ["payload", "stdout"] — Payload local cache + structured
|
|
* JSON to stdout (operator wires a log shipper to the centralized aggregator).
|
|
*
|
|
* In production, AUDIT_PSEUDONYM_SALT env var MUST be set. Boot fails fast
|
|
* if not — better to refuse to start than to ship audit data with a dev-fallback
|
|
* salt that an attacker could reverse.
|
|
*
|
|
* Note: Phase 4 wraps the returned auditLog in TraceIdEnrichingAuditLog
|
|
* for OTel correlation. Phase 2 returns the inner sink/fan-out directly.
|
|
*/
|
|
export function bindAudit(
|
|
container: Container,
|
|
opts: BindAuditOpts = {},
|
|
): { auditLog: IAuditLog } {
|
|
if (process.env.NODE_ENV === "production" && !process.env.AUDIT_PSEUDONYM_SALT) {
|
|
throw new Error(
|
|
"AUDIT_PSEUDONYM_SALT environment variable is required in production. " +
|
|
"Generate via `openssl rand -hex 32` and store in your secrets manager.",
|
|
);
|
|
}
|
|
|
|
const sinkList = opts.sinks ?? ["payload", "stdout"];
|
|
const sinks: IAuditLog[] = [];
|
|
if (sinkList.includes("payload") && opts.payloadConfig) {
|
|
sinks.push(new PayloadAuditLog(opts.payloadConfig, getPayload));
|
|
}
|
|
if (sinkList.includes("stdout")) {
|
|
sinks.push(new StdoutJsonAuditLog());
|
|
}
|
|
|
|
const auditLog: IAuditLog =
|
|
sinks.length > 1 ? new MultiSinkAuditLog(sinks)
|
|
: sinks.length === 1 ? sinks[0]!
|
|
: new NoopAuditLog();
|
|
|
|
if (container.isBound(AUDIT_SYMBOLS.IAuditLog)) {
|
|
container.unbind(AUDIT_SYMBOLS.IAuditLog);
|
|
}
|
|
container.bind<IAuditLog>(AUDIT_SYMBOLS.IAuditLog).toConstantValue(auditLog);
|
|
|
|
return { auditLog };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run → PASS**
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/core-audit/src/di/bind-audit.ts packages/core-audit/src/di/bind-audit.test.ts
|
|
git commit -m "feat(core-audit): bindAudit binder with sink selection + prod salt validation"
|
|
```
|
|
|
|
### Task 2.9: RecordingAuditLog in core-testing (TDD)
|
|
|
|
**Files:**
|
|
- Create: `packages/core-testing/src/instrumentation/recording-audit-log.ts` (+ test)
|
|
- Modify: `packages/core-testing/src/instrumentation/index.ts`
|
|
|
|
- [ ] **Step 1: Write failing test**
|
|
|
|
Create `packages/core-testing/src/instrumentation/recording-audit-log.test.ts`:
|
|
|
|
```ts
|
|
import { describe, it, expect } from "vitest";
|
|
import { RecordingAuditLog } from "./recording-audit-log";
|
|
import type { AuditEntry } from "@repo/core-shared/audit";
|
|
|
|
const sample: AuditEntry = {
|
|
actorId: "user_1",
|
|
actorType: "user",
|
|
actorRoles: [],
|
|
action: "CREATE",
|
|
resource: { type: "articles" },
|
|
at: new Date(),
|
|
scope: { feature: "blog", environment: "test", tenant: "default" },
|
|
from: { ipTruncated: "10.0.0.0", userAgent: "test" },
|
|
containsPii: false,
|
|
outcome: "success",
|
|
};
|
|
|
|
describe("RecordingAuditLog", () => {
|
|
it("record() pushes to recorded[]", async () => {
|
|
const log = new RecordingAuditLog();
|
|
await log.record(sample);
|
|
expect(log.recorded).toHaveLength(1);
|
|
expect(log.recorded[0]!.actorId).toBe("user_1");
|
|
});
|
|
|
|
it("eraseSubject(pseudonymize) tracks erasure + rewrites actorId in recorded", async () => {
|
|
const log = new RecordingAuditLog();
|
|
await log.record(sample);
|
|
await log.eraseSubject("user_1", "pseudonymize");
|
|
expect(log.erasures).toEqual([{ actorId: "user_1", mode: "pseudonymize" }]);
|
|
expect(log.recorded[0]!.actorId).toBe("erased-user_1"); // sentinel rewrite
|
|
});
|
|
|
|
it("eraseSubject(delete) removes matching entries from recorded", async () => {
|
|
const log = new RecordingAuditLog();
|
|
await log.record(sample);
|
|
await log.record({ ...sample, actorId: "user_2" });
|
|
await log.eraseSubject("user_1", "delete");
|
|
expect(log.recorded.map((r) => r.actorId)).toEqual(["user_2"]);
|
|
expect(log.erasures).toEqual([{ actorId: "user_1", mode: "delete" }]);
|
|
});
|
|
|
|
it("reset() clears recorded + erasures", async () => {
|
|
const log = new RecordingAuditLog();
|
|
await log.record(sample);
|
|
await log.eraseSubject("user_1", "pseudonymize");
|
|
log.reset();
|
|
expect(log.recorded).toEqual([]);
|
|
expect(log.erasures).toEqual([]);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run → FAIL**
|
|
|
|
```bash
|
|
pnpm --filter @repo/core-testing test recording-audit-log.test
|
|
```
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
Create `packages/core-testing/src/instrumentation/recording-audit-log.ts`:
|
|
|
|
```ts
|
|
import type { AuditEntry } from "@repo/core-shared/audit";
|
|
|
|
/**
|
|
* Test-side recording double for IAuditLog. Mirrors Payload semantics in
|
|
* eraseSubject (pseudonymize rewrites in place; delete filters out) so tests
|
|
* can assert against the same observable state the real impl produces.
|
|
*
|
|
* Use directly via constructor injection in factory-function tests — no
|
|
* container manipulation needed.
|
|
*/
|
|
export class RecordingAuditLog {
|
|
public recorded: AuditEntry[] = [];
|
|
public erasures: { actorId: string; mode: "pseudonymize" | "delete" }[] = [];
|
|
|
|
async record(entry: AuditEntry): Promise<void> {
|
|
this.recorded.push(entry);
|
|
}
|
|
|
|
async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
|
|
this.erasures.push({ actorId, mode });
|
|
if (mode === "pseudonymize") {
|
|
for (const r of this.recorded) {
|
|
if (r.actorId === actorId) {
|
|
r.actorId = `erased-${actorId}`;
|
|
}
|
|
}
|
|
} else {
|
|
this.recorded = this.recorded.filter((r) => r.actorId !== actorId);
|
|
}
|
|
}
|
|
|
|
reset(): void {
|
|
this.recorded = [];
|
|
this.erasures = [];
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run → PASS**
|
|
|
|
- [ ] **Step 5: Update core-testing barrel**
|
|
|
|
Append to `packages/core-testing/src/instrumentation/index.ts`:
|
|
|
|
```ts
|
|
export { RecordingAuditLog } from "./recording-audit-log";
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add packages/core-testing/src/instrumentation/recording-audit-log.ts \
|
|
packages/core-testing/src/instrumentation/recording-audit-log.test.ts \
|
|
packages/core-testing/src/instrumentation/index.ts
|
|
git commit -m "feat(core-testing): RecordingAuditLog test double"
|
|
```
|
|
|
|
### Task 2.10: Package barrel + Phase 2 gate
|
|
|
|
**Files:**
|
|
- Create: `packages/core-audit/src/index.ts`
|
|
|
|
- [ ] **Step 1: Create barrel**
|
|
|
|
Create `packages/core-audit/src/index.ts`:
|
|
|
|
```ts
|
|
export type { IAuditLog } from "./audit-log.interface";
|
|
export type { AuditEntry, AuditAction, AuditFrom } from "@repo/core-shared/audit";
|
|
export { NoopAuditLog } from "./noop-audit-log";
|
|
export { StdoutJsonAuditLog } from "./stdout-json-audit-log";
|
|
export { PayloadAuditLog } from "./payload-audit-log";
|
|
export { MultiSinkAuditLog } from "./multi-sink-audit-log";
|
|
export { auditLogsCollection } from "./audit-logs-collection";
|
|
export { bindAudit, type BindAuditOpts } from "./di/bind-audit";
|
|
export { AUDIT_SYMBOLS } from "./di/symbols";
|
|
```
|
|
|
|
- [ ] **Step 2: Run all gates**
|
|
|
|
```bash
|
|
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries
|
|
```
|
|
Expected: all green.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add packages/core-audit/src/index.ts
|
|
git commit -m "feat(core-audit): package barrel exports"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 3 — GDPR erasure plumbing
|
|
|
|
**Goal:** make erasure actually invokable. `pseudonymize` helper, `PayloadAuditLog.eraseSubject` impl, `createAuditErasureHook` factory, admin tRPC procedure.
|
|
|
|
**Files touched:**
|
|
|
|
- Create: `packages/core-audit/src/pseudonymize.ts` (+ test)
|
|
- Modify: `packages/core-audit/src/payload-audit-log.ts` (eraseSubject impl)
|
|
- Update: `packages/core-audit/src/payload-audit-log.test.ts` (add eraseSubject tests)
|
|
- Create: `packages/core-audit/src/hooks/audit-erasure-hook.ts` (+ test)
|
|
- Create: `packages/core-audit/src/hooks/index.ts`
|
|
- 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`
|
|
|
|
### Task 3.1: pseudonymize helper (TDD)
|
|
|
|
**Files:**
|
|
- Create: `packages/core-audit/src/pseudonymize.ts` (+ test)
|
|
|
|
- [ ] **Step 1: Write failing test**
|
|
|
|
Create `packages/core-audit/src/pseudonymize.test.ts`:
|
|
|
|
```ts
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { pseudonymize } from "./pseudonymize";
|
|
|
|
describe("pseudonymize", () => {
|
|
let oldSalt: string | undefined;
|
|
beforeEach(() => {
|
|
oldSalt = process.env.AUDIT_PSEUDONYM_SALT;
|
|
process.env.AUDIT_PSEUDONYM_SALT = "test-salt-1";
|
|
});
|
|
afterEach(() => {
|
|
if (oldSalt) process.env.AUDIT_PSEUDONYM_SALT = oldSalt;
|
|
else delete process.env.AUDIT_PSEUDONYM_SALT;
|
|
});
|
|
|
|
it("returns 'erased-{16-hex-chars}'", () => {
|
|
const result = pseudonymize("user_1");
|
|
expect(result).toMatch(/^erased-[a-f0-9]{16}$/);
|
|
});
|
|
|
|
it("is deterministic — same input → same output", () => {
|
|
const a = pseudonymize("user_1");
|
|
const b = pseudonymize("user_1");
|
|
expect(a).toBe(b);
|
|
});
|
|
|
|
it("different inputs produce different outputs", () => {
|
|
const a = pseudonymize("user_1");
|
|
const b = pseudonymize("user_2");
|
|
expect(a).not.toBe(b);
|
|
});
|
|
|
|
it("salt change produces different output", () => {
|
|
const a = pseudonymize("user_1");
|
|
process.env.AUDIT_PSEUDONYM_SALT = "test-salt-2";
|
|
const b = pseudonymize("user_1");
|
|
expect(a).not.toBe(b);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run → FAIL**
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
Create `packages/core-audit/src/pseudonymize.ts`:
|
|
|
|
```ts
|
|
import { createHash } from "node:crypto";
|
|
|
|
/**
|
|
* Stable pseudonym for an erased actor. SHA-256 of (salt + ":" + actorId),
|
|
* truncated to 16 hex chars, prefixed `erased-`. Returns the same pseudonym
|
|
* for the same input + salt — compliance auditors can verify that two
|
|
* entries with `erased-abc...` came from the same original actor without
|
|
* knowing who.
|
|
*
|
|
* The salt comes from AUDIT_PSEUDONYM_SALT env. In production, the binder
|
|
* (bindAudit) validates this env is set; in dev/test, a fallback salt is
|
|
* used (NOT acceptable for production data).
|
|
*/
|
|
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)}`;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run → PASS**
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/core-audit/src/pseudonymize.ts packages/core-audit/src/pseudonymize.test.ts
|
|
git commit -m "feat(core-audit): pseudonymize helper (sha256 + AUDIT_PSEUDONYM_SALT)"
|
|
```
|
|
|
|
### Task 3.2: PayloadAuditLog.eraseSubject (TDD)
|
|
|
|
**Files:**
|
|
- Modify: `packages/core-audit/src/payload-audit-log.ts`
|
|
- Modify: `packages/core-audit/src/payload-audit-log.test.ts`
|
|
|
|
- [ ] **Step 1: Add failing tests to existing test file**
|
|
|
|
Append to `packages/core-audit/src/payload-audit-log.test.ts`:
|
|
|
|
```ts
|
|
describe("PayloadAuditLog.eraseSubject", () => {
|
|
it("mode='delete' calls payload.delete with overrideAccess + where actorId equals", async () => {
|
|
const mockDelete = vi.fn().mockResolvedValue({ docs: [] });
|
|
const mockGetPayload = vi.fn().mockResolvedValue({ delete: mockDelete, create: vi.fn() });
|
|
const log = new PayloadAuditLog({} as never, mockGetPayload);
|
|
|
|
await log.eraseSubject("user_1", "delete");
|
|
|
|
expect(mockDelete).toHaveBeenCalledOnce();
|
|
const call = mockDelete.mock.calls[0]![0] as {
|
|
collection: string;
|
|
where: { actorId: { equals: string } };
|
|
overrideAccess: boolean;
|
|
};
|
|
expect(call.collection).toBe("audit-logs");
|
|
expect(call.where.actorId.equals).toBe("user_1");
|
|
expect(call.overrideAccess).toBe(true);
|
|
});
|
|
|
|
it("mode='pseudonymize' fetches matching docs + updates each with pseudonym + overrideAccess", async () => {
|
|
process.env.AUDIT_PSEUDONYM_SALT = "fixed-salt-for-test";
|
|
const mockFind = vi.fn().mockResolvedValue({ docs: [{ id: "doc_1" }, { id: "doc_2" }] });
|
|
const mockUpdate = vi.fn().mockResolvedValue({});
|
|
const mockGetPayload = vi.fn().mockResolvedValue({ find: mockFind, update: mockUpdate, create: vi.fn() });
|
|
const log = new PayloadAuditLog({} as never, mockGetPayload);
|
|
|
|
await log.eraseSubject("user_1", "pseudonymize");
|
|
|
|
expect(mockFind).toHaveBeenCalledOnce();
|
|
expect(mockUpdate).toHaveBeenCalledTimes(2);
|
|
const firstUpdate = mockUpdate.mock.calls[0]![0] as {
|
|
data: { actorId: string };
|
|
overrideAccess: boolean;
|
|
};
|
|
expect(firstUpdate.data.actorId).toMatch(/^erased-[a-f0-9]{16}$/);
|
|
expect(firstUpdate.overrideAccess).toBe(true);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run → FAIL**
|
|
|
|
```bash
|
|
pnpm --filter @repo/core-audit test payload-audit-log.test
|
|
```
|
|
|
|
(The existing `record` test still passes; the new tests fail because eraseSubject still throws.)
|
|
|
|
- [ ] **Step 3: Replace the eraseSubject stub with the impl**
|
|
|
|
In `packages/core-audit/src/payload-audit-log.ts`, replace the `eraseSubject` body:
|
|
|
|
```ts
|
|
import { pseudonymize } from "./pseudonymize";
|
|
|
|
// ... existing class ...
|
|
|
|
async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
|
|
const payload = await this.getPayload({ config: this.config });
|
|
if (mode === "delete") {
|
|
await payload.delete({
|
|
collection: "audit-logs",
|
|
where: { actorId: { equals: actorId } },
|
|
overrideAccess: true,
|
|
});
|
|
return;
|
|
}
|
|
// pseudonymize
|
|
const pseudonym = pseudonymize(actorId);
|
|
const matches = await payload.find({
|
|
collection: "audit-logs",
|
|
where: { actorId: { equals: actorId } },
|
|
limit: 10_000,
|
|
overrideAccess: true,
|
|
});
|
|
for (const doc of matches.docs) {
|
|
await payload.update({
|
|
collection: "audit-logs",
|
|
id: doc.id,
|
|
data: { actorId: pseudonym },
|
|
overrideAccess: true,
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run → PASS**
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/core-audit/src/payload-audit-log.ts packages/core-audit/src/payload-audit-log.test.ts
|
|
git commit -m "feat(core-audit): PayloadAuditLog.eraseSubject (pseudonymize + delete via overrideAccess)"
|
|
```
|
|
|
|
### Task 3.3: createAuditErasureHook factory (TDD)
|
|
|
|
**Files:**
|
|
- Create: `packages/core-audit/src/hooks/audit-erasure-hook.ts` (+ test)
|
|
- Create: `packages/core-audit/src/hooks/index.ts`
|
|
|
|
- [ ] **Step 1: Write failing test**
|
|
|
|
Create `packages/core-audit/src/hooks/audit-erasure-hook.test.ts`:
|
|
|
|
```ts
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { createAuditErasureHook } from "./audit-erasure-hook";
|
|
import type { IAuditLog } from "../audit-log.interface";
|
|
|
|
function makeAuditLog(): IAuditLog & { erasures: { actorId: string; mode: string }[] } {
|
|
const erasures: { actorId: string; mode: string }[] = [];
|
|
return {
|
|
erasures,
|
|
record: vi.fn(),
|
|
async eraseSubject(actorId, mode) { erasures.push({ actorId, mode }); },
|
|
};
|
|
}
|
|
|
|
describe("createAuditErasureHook", () => {
|
|
it("calls auditLog.eraseSubject(doc.id, 'pseudonymize') by default", async () => {
|
|
const auditLog = makeAuditLog();
|
|
const hook = createAuditErasureHook({ auditLog });
|
|
await hook({ doc: { id: "user_1" } } as never);
|
|
expect(auditLog.erasures).toEqual([{ actorId: "user_1", mode: "pseudonymize" }]);
|
|
});
|
|
|
|
it("uses mode='delete' when configured", async () => {
|
|
const auditLog = makeAuditLog();
|
|
const hook = createAuditErasureHook({ auditLog, mode: "delete" });
|
|
await hook({ doc: { id: "user_1" } } as never);
|
|
expect(auditLog.erasures).toEqual([{ actorId: "user_1", mode: "delete" }]);
|
|
});
|
|
|
|
it("coerces numeric doc.id to string", async () => {
|
|
const auditLog = makeAuditLog();
|
|
const hook = createAuditErasureHook({ auditLog });
|
|
await hook({ doc: { id: 42 } } as never);
|
|
expect(auditLog.erasures).toEqual([{ actorId: "42", mode: "pseudonymize" }]);
|
|
});
|
|
|
|
it("skips non-string/non-numeric ids", async () => {
|
|
const auditLog = makeAuditLog();
|
|
const hook = createAuditErasureHook({ auditLog });
|
|
await hook({ doc: { id: undefined } } as never);
|
|
expect(auditLog.erasures).toEqual([]);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run → FAIL**
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
Create `packages/core-audit/src/hooks/audit-erasure-hook.ts`:
|
|
|
|
```ts
|
|
import type { CollectionAfterDeleteHook } from "payload";
|
|
import type { IAuditLog } from "../audit-log.interface";
|
|
|
|
export type AuditErasureHookOpts = {
|
|
auditLog: IAuditLog;
|
|
/** Default 'pseudonymize'. Use 'delete' for collections requiring hard-erase. */
|
|
mode?: "pseudonymize" | "delete";
|
|
};
|
|
|
|
/**
|
|
* Payload afterDelete hook that triggers GDPR erasure on the audit log
|
|
* when a subject (typically a user) is deleted. Install on user-shaped
|
|
* collections via the collection's `hooks.afterDelete` array.
|
|
*
|
|
* Has no knowledge of collection schemas beyond expecting a `doc.id`
|
|
* (string or number). Works for any subject-shaped collection.
|
|
*/
|
|
export function createAuditErasureHook(
|
|
opts: AuditErasureHookOpts,
|
|
): CollectionAfterDeleteHook {
|
|
const mode = opts.mode ?? "pseudonymize";
|
|
return async ({ doc }) => {
|
|
const id = doc.id;
|
|
if (typeof id !== "string" && typeof id !== "number") return;
|
|
await opts.auditLog.eraseSubject(String(id), mode);
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Create hooks barrel**
|
|
|
|
Create `packages/core-audit/src/hooks/index.ts`:
|
|
|
|
```ts
|
|
export {
|
|
createAuditErasureHook,
|
|
type AuditErasureHookOpts,
|
|
} from "./audit-erasure-hook";
|
|
```
|
|
|
|
- [ ] **Step 5: Run → PASS**
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add packages/core-audit/src/hooks/audit-erasure-hook.ts \
|
|
packages/core-audit/src/hooks/audit-erasure-hook.test.ts \
|
|
packages/core-audit/src/hooks/index.ts
|
|
git commit -m "feat(core-audit): createAuditErasureHook Payload afterDelete factory"
|
|
```
|
|
|
|
### Task 3.4: Admin tRPC procedure (TDD)
|
|
|
|
**Files:**
|
|
- Create: `packages/core-audit/src/integrations/api/procedures.ts`
|
|
- Create: `packages/core-audit/src/integrations/api/router.ts` (+ test)
|
|
|
|
- [ ] **Step 1: Create the procedure helper**
|
|
|
|
Create `packages/core-audit/src/integrations/api/procedures.ts`:
|
|
|
|
```ts
|
|
import { initTRPC, TRPCError } from "@trpc/server";
|
|
import type { TrpcContext } from "@repo/core-shared/trpc/context";
|
|
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
|
|
|
const t = initTRPC.context<TrpcContext>().create();
|
|
|
|
const adminOnly = t.middleware(({ ctx, next }) => {
|
|
const user = ctx.user as { roles?: string[] } | null | undefined;
|
|
if (!user?.roles?.includes("admin")) {
|
|
throw new TRPCError({ code: "FORBIDDEN", message: "Admin role required" });
|
|
}
|
|
return next();
|
|
});
|
|
|
|
/** Feature-scoped audit procedure: admin-only, no domain errors yet. */
|
|
export const auditProcedure = t.procedure
|
|
.use(defineErrorMiddleware([]))
|
|
.use(adminOnly);
|
|
|
|
export const router = t.router;
|
|
```
|
|
|
|
- [ ] **Step 2: Write failing test for the router**
|
|
|
|
Create `packages/core-audit/src/integrations/api/router.test.ts`:
|
|
|
|
```ts
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { auditRouter } from "./router";
|
|
import type { IAuditLog } from "../../audit-log.interface";
|
|
|
|
function makeCtx(opts: { admin: boolean; auditLog?: IAuditLog }) {
|
|
return {
|
|
user: opts.admin ? { id: "u1", roles: ["admin"] } : { id: "u2", roles: [] },
|
|
auditLog: opts.auditLog,
|
|
};
|
|
}
|
|
|
|
describe("auditRouter.eraseSubject", () => {
|
|
it("admin can invoke and the call delegates to ctx.auditLog.eraseSubject", async () => {
|
|
const eraseSpy = vi.fn().mockResolvedValue(undefined);
|
|
const auditLog: IAuditLog = {
|
|
record: vi.fn(),
|
|
eraseSubject: eraseSpy,
|
|
};
|
|
const caller = auditRouter.createCaller(makeCtx({ admin: true, auditLog }) as never);
|
|
const result = await caller.eraseSubject({ actorId: "user_1", mode: "pseudonymize" });
|
|
expect(result).toEqual({ ok: true });
|
|
expect(eraseSpy).toHaveBeenCalledWith("user_1", "pseudonymize");
|
|
});
|
|
|
|
it("non-admin gets FORBIDDEN", async () => {
|
|
const caller = auditRouter.createCaller(makeCtx({ admin: false }) as never);
|
|
await expect(
|
|
caller.eraseSubject({ actorId: "user_1", mode: "delete" }),
|
|
).rejects.toThrow(TRPCError);
|
|
});
|
|
|
|
it("defaults mode to 'pseudonymize' when omitted", async () => {
|
|
const eraseSpy = vi.fn().mockResolvedValue(undefined);
|
|
const auditLog: IAuditLog = { record: vi.fn(), eraseSubject: eraseSpy };
|
|
const caller = auditRouter.createCaller(makeCtx({ admin: true, auditLog }) as never);
|
|
await caller.eraseSubject({ actorId: "user_1" });
|
|
expect(eraseSpy).toHaveBeenCalledWith("user_1", "pseudonymize");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Run → FAIL**
|
|
|
|
- [ ] **Step 4: Implement the router**
|
|
|
|
Create `packages/core-audit/src/integrations/api/router.ts`:
|
|
|
|
```ts
|
|
import { z } from "zod";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { auditProcedure, router } from "./procedures";
|
|
|
|
export const auditRouter = router({
|
|
eraseSubject: auditProcedure
|
|
.input(
|
|
z.object({
|
|
actorId: z.string().min(1),
|
|
mode: z.enum(["pseudonymize", "delete"]).default("pseudonymize"),
|
|
}).strict(),
|
|
)
|
|
.mutation(async ({ input, ctx }) => {
|
|
const auditLog = (ctx as { auditLog?: { eraseSubject: (id: string, mode: "pseudonymize" | "delete") => Promise<void> } }).auditLog;
|
|
if (!auditLog) {
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "Audit log not bound on context",
|
|
});
|
|
}
|
|
await auditLog.eraseSubject(input.actorId, input.mode);
|
|
return { ok: true as const };
|
|
}),
|
|
});
|
|
|
|
export type AuditRouter = typeof auditRouter;
|
|
```
|
|
|
|
- [ ] **Step 5: Run → PASS**
|
|
|
|
- [ ] **Step 6: Update package barrel**
|
|
|
|
Modify `packages/core-audit/src/index.ts` to add:
|
|
|
|
```ts
|
|
export { auditRouter, type AuditRouter } from "./integrations/api/router";
|
|
export { createAuditErasureHook, type AuditErasureHookOpts } from "./hooks";
|
|
export { pseudonymize } from "./pseudonymize";
|
|
```
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add packages/core-audit/src/integrations/api/ \
|
|
packages/core-audit/src/index.ts
|
|
git commit -m "feat(core-audit): admin tRPC procedure for eraseSubject"
|
|
```
|
|
|
|
### Task 3.5: Phase 3 verification gate
|
|
|
|
- [ ] **Step 1: Run all gates**
|
|
|
|
```bash
|
|
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries
|
|
```
|
|
Expected: all green.
|
|
|
|
(No commit; verification only.)
|
|
|
|
---
|
|
|
|
## Phase 4 — OTel correlation bridge
|
|
|
|
**Goal:** `AuditEntry.correlationId` auto-populates from active OTel span. Decorator pattern at bind time.
|
|
|
|
**Files touched:**
|
|
|
|
- Create: `packages/core-shared/src/instrumentation/otel/current-trace-id.ts` (+ test)
|
|
- Modify: `packages/core-shared/src/instrumentation/otel/index.ts`
|
|
- Modify: `packages/core-shared/src/instrumentation/index.ts`
|
|
- Create: `packages/core-audit/src/trace-id-enriching-audit-log.ts` (+ test)
|
|
- Modify: `packages/core-audit/src/di/bind-audit.ts` (wrap with decorator)
|
|
- Modify: `packages/core-audit/src/index.ts`
|
|
|
|
### Task 4.1: currentTraceId helper (TDD)
|
|
|
|
**Files:**
|
|
- Create: `packages/core-shared/src/instrumentation/otel/current-trace-id.ts` (+ test)
|
|
|
|
- [ ] **Step 1: Write failing test**
|
|
|
|
Create `packages/core-shared/src/instrumentation/otel/current-trace-id.test.ts`:
|
|
|
|
```ts
|
|
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
|
import { trace } from "@opentelemetry/api";
|
|
import { tracing } from "@opentelemetry/sdk-node";
|
|
import { currentTraceId } from "./current-trace-id";
|
|
|
|
const exporter = new tracing.InMemorySpanExporter();
|
|
const provider = new tracing.BasicTracerProvider({
|
|
spanProcessors: [new tracing.SimpleSpanProcessor(exporter)],
|
|
});
|
|
|
|
beforeAll(() => {
|
|
trace.setGlobalTracerProvider(provider);
|
|
});
|
|
|
|
afterEach(() => exporter.reset());
|
|
|
|
describe("currentTraceId", () => {
|
|
it("returns undefined when no active span", () => {
|
|
expect(currentTraceId()).toBeUndefined();
|
|
});
|
|
|
|
it("returns the active span's traceId when inside startActiveSpan", async () => {
|
|
const tracer = trace.getTracer("test");
|
|
await new Promise<void>((resolve) => {
|
|
tracer.startActiveSpan("test-span", (span) => {
|
|
const id = currentTraceId();
|
|
expect(id).toBeDefined();
|
|
expect(id).toMatch(/^[a-f0-9]{32}$/);
|
|
span.end();
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run → FAIL**
|
|
|
|
```bash
|
|
pnpm --filter @repo/core-shared test current-trace-id.test
|
|
```
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
Create `packages/core-shared/src/instrumentation/otel/current-trace-id.ts`:
|
|
|
|
```ts
|
|
import { trace } from "@opentelemetry/api";
|
|
|
|
/**
|
|
* Returns the trace ID of the currently active OTel span, or undefined if
|
|
* there is no active span (e.g., outside any request context, in unit tests
|
|
* without an OTel SDK).
|
|
*
|
|
* Used by core-audit's TraceIdEnrichingAuditLog decorator to auto-populate
|
|
* AuditEntry.correlationId so callers don't have to thread it explicitly.
|
|
*
|
|
* Returns undefined for the all-zeros invalid trace ID — OTel emits this
|
|
* when context propagation hasn't kicked in.
|
|
*/
|
|
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;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run → PASS**
|
|
|
|
- [ ] **Step 5: Update barrels**
|
|
|
|
Append to `packages/core-shared/src/instrumentation/otel/index.ts`:
|
|
|
|
```ts
|
|
export { currentTraceId } from "./current-trace-id";
|
|
```
|
|
|
|
Append to `packages/core-shared/src/instrumentation/index.ts`:
|
|
|
|
```ts
|
|
export { currentTraceId } from "./otel/current-trace-id";
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add packages/core-shared/src/instrumentation/otel/current-trace-id.ts \
|
|
packages/core-shared/src/instrumentation/otel/current-trace-id.test.ts \
|
|
packages/core-shared/src/instrumentation/otel/index.ts \
|
|
packages/core-shared/src/instrumentation/index.ts
|
|
git commit -m "feat(core-shared): currentTraceId helper for OTel-audit correlation bridge"
|
|
```
|
|
|
|
### Task 4.2: TraceIdEnrichingAuditLog decorator (TDD)
|
|
|
|
**Files:**
|
|
- Create: `packages/core-audit/src/trace-id-enriching-audit-log.ts` (+ test)
|
|
|
|
- [ ] **Step 1: Write failing test**
|
|
|
|
Create `packages/core-audit/src/trace-id-enriching-audit-log.test.ts`:
|
|
|
|
```ts
|
|
import { describe, it, expect, beforeAll, afterEach, vi } from "vitest";
|
|
import { trace } from "@opentelemetry/api";
|
|
import { tracing } from "@opentelemetry/sdk-node";
|
|
import { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log";
|
|
import type { AuditEntry } from "@repo/core-shared/audit";
|
|
import type { IAuditLog } from "./audit-log.interface";
|
|
|
|
const exporter = new tracing.InMemorySpanExporter();
|
|
const provider = new tracing.BasicTracerProvider({
|
|
spanProcessors: [new tracing.SimpleSpanProcessor(exporter)],
|
|
});
|
|
|
|
beforeAll(() => trace.setGlobalTracerProvider(provider));
|
|
afterEach(() => exporter.reset());
|
|
|
|
const sample: AuditEntry = {
|
|
actorId: "user_1",
|
|
actorType: "user",
|
|
actorRoles: [],
|
|
action: "VIEW",
|
|
resource: { type: "articles" },
|
|
at: new Date(),
|
|
scope: { feature: "blog", environment: "test", tenant: "default" },
|
|
from: { ipTruncated: "10.0.0.0", userAgent: "test" },
|
|
containsPii: false,
|
|
outcome: "success",
|
|
};
|
|
|
|
function makeInner(): IAuditLog & { records: AuditEntry[] } {
|
|
const records: AuditEntry[] = [];
|
|
return {
|
|
records,
|
|
async record(e) { records.push(e); },
|
|
eraseSubject: vi.fn(),
|
|
};
|
|
}
|
|
|
|
describe("TraceIdEnrichingAuditLog", () => {
|
|
it("passes through when no active span", async () => {
|
|
const inner = makeInner();
|
|
const wrapper = new TraceIdEnrichingAuditLog(inner);
|
|
await wrapper.record(sample);
|
|
expect(inner.records[0]!.correlationId).toBeUndefined();
|
|
});
|
|
|
|
it("auto-populates correlationId from active span", async () => {
|
|
const inner = makeInner();
|
|
const wrapper = new TraceIdEnrichingAuditLog(inner);
|
|
const tracer = trace.getTracer("test");
|
|
await new Promise<void>((resolve) => {
|
|
tracer.startActiveSpan("test", async (span) => {
|
|
await wrapper.record(sample);
|
|
const expected = span.spanContext().traceId;
|
|
expect(inner.records[0]!.correlationId).toBe(expected);
|
|
span.end();
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
|
|
it("explicit correlationId wins over auto-populated", async () => {
|
|
const inner = makeInner();
|
|
const wrapper = new TraceIdEnrichingAuditLog(inner);
|
|
const tracer = trace.getTracer("test");
|
|
await new Promise<void>((resolve) => {
|
|
tracer.startActiveSpan("test", async (span) => {
|
|
await wrapper.record({ ...sample, correlationId: "explicit-trace-id" });
|
|
expect(inner.records[0]!.correlationId).toBe("explicit-trace-id");
|
|
span.end();
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
|
|
it("eraseSubject passes through unchanged", async () => {
|
|
const eraseSpy = vi.fn();
|
|
const inner: IAuditLog = { record: vi.fn(), eraseSubject: eraseSpy };
|
|
const wrapper = new TraceIdEnrichingAuditLog(inner);
|
|
await wrapper.eraseSubject("user_1", "delete");
|
|
expect(eraseSpy).toHaveBeenCalledWith("user_1", "delete");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run → FAIL**
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
Create `packages/core-audit/src/trace-id-enriching-audit-log.ts`:
|
|
|
|
```ts
|
|
import type { AuditEntry } from "@repo/core-shared/audit";
|
|
import { currentTraceId } from "@repo/core-shared/instrumentation";
|
|
import type { IAuditLog } from "./audit-log.interface";
|
|
|
|
/**
|
|
* Decorates any IAuditLog by auto-populating AuditEntry.correlationId from
|
|
* the active OTel span (when present and the caller didn't supply a value).
|
|
* Caller-supplied correlationId always wins — explicit > implicit.
|
|
*
|
|
* Applied at bind time by bindAudit so all sinks see entries with
|
|
* correlationId already set. Single source of truth for the OTel-audit bridge.
|
|
*/
|
|
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);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run → PASS**
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/core-audit/src/trace-id-enriching-audit-log.ts \
|
|
packages/core-audit/src/trace-id-enriching-audit-log.test.ts
|
|
git commit -m "feat(core-audit): TraceIdEnrichingAuditLog decorator for OTel correlation"
|
|
```
|
|
|
|
### Task 4.3: Wire decorator into bindAudit + update barrel
|
|
|
|
**Files:**
|
|
- Modify: `packages/core-audit/src/di/bind-audit.ts`
|
|
- Modify: `packages/core-audit/src/di/bind-audit.test.ts`
|
|
- Modify: `packages/core-audit/src/index.ts`
|
|
|
|
- [ ] **Step 1: Update bindAudit to wrap with the decorator**
|
|
|
|
In `packages/core-audit/src/di/bind-audit.ts`, find the line:
|
|
|
|
```ts
|
|
const auditLog: IAuditLog =
|
|
sinks.length > 1 ? new MultiSinkAuditLog(sinks)
|
|
: sinks.length === 1 ? sinks[0]!
|
|
: new NoopAuditLog();
|
|
```
|
|
|
|
Replace with:
|
|
|
|
```ts
|
|
import { TraceIdEnrichingAuditLog } from "../trace-id-enriching-audit-log";
|
|
|
|
const inner: IAuditLog =
|
|
sinks.length > 1 ? new MultiSinkAuditLog(sinks)
|
|
: sinks.length === 1 ? sinks[0]!
|
|
: new NoopAuditLog();
|
|
const auditLog: IAuditLog = new TraceIdEnrichingAuditLog(inner);
|
|
```
|
|
|
|
- [ ] **Step 2: Update bind-audit test**
|
|
|
|
Existing tests assert `instanceof MultiSinkAuditLog` / `instanceof StdoutJsonAuditLog`. Now those instances are wrapped in `TraceIdEnrichingAuditLog`. Update each `expect(auditLog).toBeInstanceOf(X)` to:
|
|
|
|
```ts
|
|
expect(auditLog).toBeInstanceOf(TraceIdEnrichingAuditLog);
|
|
// Use a typed accessor to assert inner type:
|
|
expect((auditLog as unknown as { inner: unknown }).inner).toBeInstanceOf(X);
|
|
```
|
|
|
|
(Add `import { TraceIdEnrichingAuditLog } from "../trace-id-enriching-audit-log";` to the test file.)
|
|
|
|
- [ ] **Step 3: Run → PASS**
|
|
|
|
```bash
|
|
pnpm --filter @repo/core-audit test bind-audit.test
|
|
```
|
|
|
|
- [ ] **Step 4: Update package barrel**
|
|
|
|
Modify `packages/core-audit/src/index.ts` to add:
|
|
|
|
```ts
|
|
export { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log";
|
|
```
|
|
|
|
- [ ] **Step 5: Phase 4 verification gate**
|
|
|
|
```bash
|
|
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries
|
|
```
|
|
Expected: all green.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add packages/core-audit/src/di/bind-audit.ts \
|
|
packages/core-audit/src/di/bind-audit.test.ts \
|
|
packages/core-audit/src/index.ts
|
|
git commit -m "feat(core-audit): wrap bound auditLog with TraceIdEnrichingAuditLog"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 5 — VIEW capture: createAuditAfterReadHook
|
|
|
|
**Goal:** ship the `afterRead` hook factory for opt-in automatic VIEW capture.
|
|
|
|
**Files touched:**
|
|
|
|
- Create: `packages/core-audit/src/hooks/audit-after-read-hook.ts` (+ test)
|
|
- Modify: `packages/core-audit/src/hooks/index.ts`
|
|
- Modify: `packages/core-audit/src/index.ts`
|
|
|
|
### Task 5.1: createAuditAfterReadHook (TDD)
|
|
|
|
**Files:**
|
|
- Create: `packages/core-audit/src/hooks/audit-after-read-hook.ts` (+ test)
|
|
|
|
- [ ] **Step 1: Write failing test**
|
|
|
|
Create `packages/core-audit/src/hooks/audit-after-read-hook.test.ts`:
|
|
|
|
```ts
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { createAuditAfterReadHook } from "./audit-after-read-hook";
|
|
import type { AuditEntry } from "@repo/core-shared/audit";
|
|
import type { IAuditLog } from "../audit-log.interface";
|
|
|
|
function makeAuditLog(): IAuditLog & { recorded: AuditEntry[] } {
|
|
const recorded: AuditEntry[] = [];
|
|
return {
|
|
recorded,
|
|
async record(e) { recorded.push(e); },
|
|
eraseSubject: vi.fn(),
|
|
};
|
|
}
|
|
|
|
function baseOpts(auditLog: IAuditLog) {
|
|
return {
|
|
auditLog,
|
|
resourceType: "users",
|
|
feature: "auth",
|
|
environment: "test",
|
|
resolveTenant: () => "default",
|
|
containsPii: true,
|
|
piiCategories: ["email"],
|
|
};
|
|
}
|
|
|
|
describe("createAuditAfterReadHook", () => {
|
|
it("emits a VIEW entry with the resource type + feature + tenant", async () => {
|
|
const auditLog = makeAuditLog();
|
|
const hook = createAuditAfterReadHook(baseOpts(auditLog));
|
|
|
|
const doc = { id: "abc", email: "x@y.com" };
|
|
const req = { user: { id: "user_1", roles: ["user"] }, headers: { "user-agent": "Mozilla" }, ip: "10.0.0.5" };
|
|
await hook({ doc, req } as never);
|
|
|
|
// Wait one tick for fire-and-forget to flush
|
|
await new Promise((r) => setImmediate(r));
|
|
|
|
expect(auditLog.recorded).toHaveLength(1);
|
|
const e = auditLog.recorded[0]!;
|
|
expect(e.action).toBe("VIEW");
|
|
expect(e.resource.type).toBe("users");
|
|
expect(e.resource.id).toBe("abc");
|
|
expect(e.actorId).toBe("user_1");
|
|
expect(e.actorRoles).toEqual(["user"]);
|
|
expect(e.scope.feature).toBe("auth");
|
|
expect(e.scope.tenant).toBe("default");
|
|
expect(e.containsPii).toBe(true);
|
|
expect(e.piiCategories).toEqual(["email"]);
|
|
expect(e.outcome).toBe("success");
|
|
expect(e.from.ipTruncated).toBe("10.0.0.0"); // /24 truncation applied
|
|
});
|
|
|
|
it("uses 'system' actor when req.user is null", async () => {
|
|
const auditLog = makeAuditLog();
|
|
const hook = createAuditAfterReadHook(baseOpts(auditLog));
|
|
await hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never);
|
|
await new Promise((r) => setImmediate(r));
|
|
expect(auditLog.recorded[0]!.actorId).toBe("system");
|
|
expect(auditLog.recorded[0]!.actorType).toBe("system");
|
|
});
|
|
|
|
it("falls back to 'internal' / 'payload-internal' sentinels when no IP/UA", async () => {
|
|
const auditLog = makeAuditLog();
|
|
const hook = createAuditAfterReadHook(baseOpts(auditLog));
|
|
await hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never);
|
|
await new Promise((r) => setImmediate(r));
|
|
expect(auditLog.recorded[0]!.from.ipTruncated).toBe("internal");
|
|
expect(auditLog.recorded[0]!.from.userAgent).toBe("payload-internal");
|
|
});
|
|
|
|
it("shouldSkip predicate prevents emission", async () => {
|
|
const auditLog = makeAuditLog();
|
|
const hook = createAuditAfterReadHook({ ...baseOpts(auditLog), shouldSkip: () => true });
|
|
await hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never);
|
|
await new Promise((r) => setImmediate(r));
|
|
expect(auditLog.recorded).toHaveLength(0);
|
|
});
|
|
|
|
it("returns the doc unchanged (afterRead hook contract)", async () => {
|
|
const auditLog = makeAuditLog();
|
|
const hook = createAuditAfterReadHook(baseOpts(auditLog));
|
|
const doc = { id: "abc", title: "Hello" };
|
|
const result = await hook({ doc, req: { user: null, headers: {} } } as never);
|
|
expect(result).toBe(doc);
|
|
});
|
|
|
|
it("audit-sink failures do not propagate (fire-and-forget)", async () => {
|
|
const auditLog: IAuditLog = {
|
|
record: async () => { throw new Error("sink-failed"); },
|
|
eraseSubject: vi.fn(),
|
|
};
|
|
const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
const hook = createAuditAfterReadHook(baseOpts(auditLog));
|
|
await expect(
|
|
hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never),
|
|
).resolves.toBeDefined();
|
|
// Give the microtask queue a moment to flush the catch handler
|
|
await new Promise((r) => setImmediate(r));
|
|
expect(errSpy).toHaveBeenCalled();
|
|
errSpy.mockRestore();
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run → FAIL**
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
Create `packages/core-audit/src/hooks/audit-after-read-hook.ts`:
|
|
|
|
```ts
|
|
import type { CollectionAfterReadHook } from "payload";
|
|
import type { AuditEntry } from "@repo/core-shared/audit";
|
|
import { truncateIp } from "@repo/core-shared/audit";
|
|
import type { IAuditLog } from "../audit-log.interface";
|
|
|
|
export type AuditAfterReadHookOpts = {
|
|
auditLog: IAuditLog;
|
|
/** Resource type for AuditEntry.resource.type (e.g., "users"). */
|
|
resourceType: string;
|
|
/** Feature attribution for AuditEntry.scope.feature. */
|
|
feature: string;
|
|
/** Deployment environment. */
|
|
environment: string;
|
|
/** Tenant resolver — single-tenant projects return "default". */
|
|
resolveTenant: (req: { user?: { id: string; tenantId?: string } | null }) => string;
|
|
/** Whether this collection contains PII. Propagates to every entry. */
|
|
containsPii: boolean;
|
|
/** Optional PII categories applicable to all entries from this collection. */
|
|
piiCategories?: string[];
|
|
/** Optional predicate; return true to skip emitting an entry. */
|
|
shouldSkip?: (args: { req: unknown; doc: { id: string | number } }) => boolean;
|
|
};
|
|
|
|
/**
|
|
* Payload afterRead hook factory. Emits a VIEW AuditEntry per document read.
|
|
* Per-collection opt-in: install via `hooks.afterRead: [createAuditAfterReadHook(...)]`
|
|
* on the collection config.
|
|
*
|
|
* Fire-and-forget: a failing audit sink does NOT propagate up to break the
|
|
* user-facing read. Failures emit a structured error to stderr (visible to
|
|
* the same log shipper as audit entries themselves).
|
|
*
|
|
* Combine with use-case-level record() calls for app-facing reads; this hook
|
|
* covers direct CMS/admin/programmatic reads. The use-case path captures
|
|
* "why" (reason); this hook captures "the system saw this doc".
|
|
*/
|
|
export function createAuditAfterReadHook(
|
|
opts: AuditAfterReadHookOpts,
|
|
): CollectionAfterReadHook {
|
|
return ({ doc, req }) => {
|
|
if (opts.shouldSkip?.({ req, doc: doc as { id: string | number } })) {
|
|
return doc;
|
|
}
|
|
|
|
const actor = (req as { user?: { id: string; roles?: string[]; tenantId?: string } | null }).user;
|
|
const entry: AuditEntry = {
|
|
actorId: actor?.id ?? "system",
|
|
actorType: actor ? "user" : "system",
|
|
actorRoles: actor?.roles ?? [],
|
|
action: "VIEW",
|
|
resource: {
|
|
type: opts.resourceType,
|
|
id: typeof doc.id === "string" || typeof doc.id === "number" ? String(doc.id) : undefined,
|
|
},
|
|
at: new Date(),
|
|
scope: {
|
|
feature: opts.feature,
|
|
environment: opts.environment,
|
|
tenant: opts.resolveTenant(req as { user?: { id: string; tenantId?: string } | null }),
|
|
},
|
|
reason: "payload-afterRead-hook",
|
|
from: {
|
|
ipTruncated: extractIpTruncated(req) ?? "internal",
|
|
userAgent: extractUserAgent(req) ?? "payload-internal",
|
|
},
|
|
containsPii: opts.containsPii,
|
|
piiCategories: opts.piiCategories,
|
|
outcome: "success",
|
|
};
|
|
|
|
// Fire-and-forget — never break the read.
|
|
void opts.auditLog.record(entry).catch((err: unknown) => {
|
|
process.stderr.write(
|
|
JSON.stringify({
|
|
_type: "audit-hook-error",
|
|
hook: "afterRead",
|
|
resourceType: opts.resourceType,
|
|
error: String(err),
|
|
at: new Date().toISOString(),
|
|
}) + "\n",
|
|
);
|
|
});
|
|
|
|
return doc;
|
|
};
|
|
}
|
|
|
|
function extractIpTruncated(req: unknown): string | undefined {
|
|
const r = req as { ip?: string; headers?: Record<string, string | string[] | undefined> };
|
|
const rawIp = r.ip ?? r.headers?.["x-forwarded-for"];
|
|
if (!rawIp) return undefined;
|
|
const candidate = Array.isArray(rawIp) ? rawIp[0]! : rawIp.split(",")[0]!.trim();
|
|
try {
|
|
return truncateIp(candidate);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function extractUserAgent(req: unknown): string | undefined {
|
|
const r = req as { headers?: Record<string, string | string[] | undefined> };
|
|
const ua = r.headers?.["user-agent"];
|
|
if (!ua) return undefined;
|
|
return Array.isArray(ua) ? ua[0] : ua;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run → PASS**
|
|
|
|
- [ ] **Step 5: Update hooks barrel + package barrel**
|
|
|
|
Append to `packages/core-audit/src/hooks/index.ts`:
|
|
|
|
```ts
|
|
export {
|
|
createAuditAfterReadHook,
|
|
type AuditAfterReadHookOpts,
|
|
} from "./audit-after-read-hook";
|
|
```
|
|
|
|
Modify `packages/core-audit/src/index.ts` to add:
|
|
|
|
```ts
|
|
export { createAuditAfterReadHook, type AuditAfterReadHookOpts } from "./hooks";
|
|
```
|
|
|
|
- [ ] **Step 6: Phase 5 verification gate**
|
|
|
|
```bash
|
|
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries
|
|
```
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add packages/core-audit/src/hooks/audit-after-read-hook.ts \
|
|
packages/core-audit/src/hooks/audit-after-read-hook.test.ts \
|
|
packages/core-audit/src/hooks/index.ts \
|
|
packages/core-audit/src/index.ts
|
|
git commit -m "feat(core-audit): createAuditAfterReadHook factory for opt-in VIEW capture"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 6 — ADR-018 + generator template + docs
|
|
|
|
**Goal:** publish ADR-018, write `docs/guides/audit-and-compliance.md`, capture core-audit as a generator template (`pnpm turbo gen core-package audit`), update CLAUDE.md / AGENTS.md / template-tiers / README / data-flow-explainer / scaffolding-doc.
|
|
|
|
**Files touched:**
|
|
|
|
- Create: `docs/decisions/adr-018-audit-and-compliance.md`
|
|
- Create: `docs/guides/audit-and-compliance.md`
|
|
- Create: `turbo/generators/templates/core-package/audit/**` (all package files as `.hbs`)
|
|
- Create: `turbo/generators/__snapshots__/core-package/audit.snapshot.json`
|
|
- Create: `turbo/generators/__tests__/core-package-audit.e2e.test.ts`
|
|
- Modify: `turbo/generators/config.ts` (push `audit` entry; add to choices)
|
|
- Modify: `docs/architecture/template-tiers.md`
|
|
- Modify: `docs/scaffolding/core-package-generator.md`
|
|
- Modify: `CLAUDE.md`
|
|
- Modify: `AGENTS.md`
|
|
- Modify: `docs/architecture/data-flow-explainer.html`
|
|
- Modify: `README.md`
|
|
|
|
### Task 6.1: Write ADR-018
|
|
|
|
**Files:**
|
|
- Create: `docs/decisions/adr-018-audit-and-compliance.md`
|
|
|
|
- [ ] **Step 1: Inspect an existing ADR for format**
|
|
|
|
```bash
|
|
head -100 docs/decisions/adr-017-opentelemetry-migration.md
|
|
```
|
|
|
|
- [ ] **Step 2: Write the ADR**
|
|
|
|
Create `docs/decisions/adr-018-audit-and-compliance.md`:
|
|
|
|
```markdown
|
|
# 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.
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add docs/decisions/adr-018-audit-and-compliance.md
|
|
git commit -m "docs(adr): ADR-018 audit logging & DPA compliance"
|
|
```
|
|
|
|
### Task 6.2: Write the audit-and-compliance guide
|
|
|
|
**Files:**
|
|
- Create: `docs/guides/audit-and-compliance.md`
|
|
|
|
- [ ] **Step 1: Write the guide**
|
|
|
|
Create `docs/guides/audit-and-compliance.md` with sections: What DPA requires; The two-pattern model; When to use which; Wiring core-audit into your app (7 steps); Sample Vector / Fluent Bit configs; GDPR erasure; Sample-week audit verification; Hostile-actor immutability test; Common mistakes.
|
|
|
|
Content (mirror the structure laid out in spec §9.3):
|
|
|
|
```markdown
|
|
# Audit logging & DPA compliance
|
|
|
|
> **Prerequisite:** This guide assumes `@repo/core-audit` is scaffolded. If your project started from the slim template, run `pnpm turbo gen core-package audit` first.
|
|
|
|
## What DPA requires
|
|
|
|
[Summarize the user-provided compliance doc: 6 action types, 4 required fields, immutability rules, "what NOT to log" list, retention 90d hot / 1y archive, deletable on GDPR request.]
|
|
|
|
## The two-pattern model
|
|
|
|
Two complementary ways to log a VIEW:
|
|
|
|
1. **Use-case-level `record()` calls** — in your feature's READ use cases, the developer explicitly calls `ctx.auditLog?.record({ action: "VIEW", ... })`. Captures the WHY (reason: "user-profile-render") and works in any context (HTTP, jobs, CLI).
|
|
|
|
2. **Payload `afterRead` hook (automatic, opt-in)** — install `createAuditAfterReadHook(...)` on a collection. Captures EVERY read of the collection automatically, including admin UI / direct programmatic reads.
|
|
|
|
Use both for collections under DPA scope. The hook covers reads you might forget at the use-case layer; the use-case calls add the contextual reason.
|
|
|
|
## When to use which
|
|
|
|
| Read source | Pattern |
|
|
|---|---|
|
|
| tRPC procedure (app-facing read) | Use-case-level `record()` call |
|
|
| Payload admin UI | Hook automatically captures |
|
|
| Background job | Use-case-level `record()` call with `actorId: "system"` |
|
|
| Direct programmatic / CMS REST | Hook automatically captures |
|
|
|
|
## Wiring core-audit into your app (7 steps)
|
|
|
|
[Reproduces the 7-step printed next-steps content from `printAuditNextSteps`.]
|
|
|
|
## Sample log-shipper configs
|
|
|
|
### Vector
|
|
|
|
[Sample Vector config that reads stdout, filters by `_type: "audit"`, ships to Grafana Loki EU.]
|
|
|
|
### Fluent Bit
|
|
|
|
[Sample Fluent Bit config equivalent.]
|
|
|
|
## GDPR erasure
|
|
|
|
Trigger via admin tRPC:
|
|
[curl example calling `audit.eraseSubject` with admin auth.]
|
|
|
|
Or rely on the user-delete hook: when a user is deleted via Payload admin, `createAuditErasureHook` automatically pseudonymizes their audit history.
|
|
|
|
## Sample-week audit verification
|
|
|
|
"Can you tell who accessed any given record?"
|
|
|
|
[Query Payload admin → audit-logs → filter by resourceType + resourceId.]
|
|
|
|
## Hostile-actor immutability test
|
|
|
|
[How to verify the append-only contract: try to update a row via direct DB access; verify Payload's overrideAccess isn't accidentally enabled elsewhere; confirm the stdout shipper has an independent retention.]
|
|
|
|
## Common mistakes
|
|
|
|
- Forgetting to set `scope.tenant` (required field).
|
|
- `containsPii: false` on a collection that actually has PII.
|
|
- Using `oldValue`/`newValue` (those fields don't exist by design — DPA enforcement).
|
|
- Forgetting `AUDIT_PSEUDONYM_SALT` in production (bindAudit fails at boot).
|
|
```
|
|
|
|
(Each placeholder section above should be expanded to a few paragraphs with real content. The doc is the canonical user-facing reference.)
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add docs/guides/audit-and-compliance.md
|
|
git commit -m "docs(guide): audit-and-compliance how-to guide"
|
|
```
|
|
|
|
### Task 6.3: Capture core-audit as a generator template
|
|
|
|
**Files:**
|
|
- Create: `turbo/generators/templates/core-package/audit/**` (all current `packages/core-audit/` files as `.hbs`)
|
|
- Create: `turbo/generators/__snapshots__/core-package/audit.snapshot.json`
|
|
|
|
- [ ] **Step 1: Mirror the package tree as `.hbs` siblings**
|
|
|
|
```bash
|
|
mkdir -p turbo/generators/templates/core-package/audit/src/{di,integrations/api,hooks}
|
|
|
|
# Top-level files
|
|
for f in AGENTS.md eslint.config.js package.json tsconfig.json turbo.json vitest.config.ts; do
|
|
cp packages/core-audit/$f turbo/generators/templates/core-package/audit/$f.hbs
|
|
done
|
|
|
|
# src files
|
|
for f in packages/core-audit/src/*.ts; do
|
|
base=$(basename "$f")
|
|
cp "$f" "turbo/generators/templates/core-package/audit/src/$base.hbs"
|
|
done
|
|
|
|
# src/di
|
|
for f in packages/core-audit/src/di/*.ts; do
|
|
base=$(basename "$f")
|
|
cp "$f" "turbo/generators/templates/core-package/audit/src/di/$base.hbs"
|
|
done
|
|
|
|
# src/hooks
|
|
for f in packages/core-audit/src/hooks/*.ts; do
|
|
base=$(basename "$f")
|
|
cp "$f" "turbo/generators/templates/core-package/audit/src/hooks/$base.hbs"
|
|
done
|
|
|
|
# src/integrations/api
|
|
for f in packages/core-audit/src/integrations/api/*.ts; do
|
|
base=$(basename "$f")
|
|
cp "$f" "turbo/generators/templates/core-package/audit/src/integrations/api/$base.hbs"
|
|
done
|
|
```
|
|
|
|
- [ ] **Step 2: Generate the byte-identical snapshot**
|
|
|
|
```bash
|
|
pnpm exec tsx <<'TS'
|
|
import { computeSnapshot } from "./turbo/generators/lib/snapshot.js";
|
|
import { writeFileSync } from "node:fs";
|
|
const snap = computeSnapshot("./packages/core-audit");
|
|
writeFileSync(
|
|
"./turbo/generators/__snapshots__/core-package/audit.snapshot.json",
|
|
JSON.stringify(snap, null, 2) + "\n",
|
|
);
|
|
console.log(`Wrote ${snap.length} entries`);
|
|
TS
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add turbo/generators/templates/core-package/audit \
|
|
turbo/generators/__snapshots__/core-package/audit.snapshot.json
|
|
git commit -m "feat(generators): capture core-audit as verbatim template files"
|
|
```
|
|
|
|
### Task 6.4: Wire audit entry into the core-package generator + e2e test
|
|
|
|
**Files:**
|
|
- Modify: `turbo/generators/config.ts`
|
|
- Create: `turbo/generators/__tests__/core-package-audit.e2e.test.ts`
|
|
|
|
- [ ] **Step 1: Find an existing per-package e2e test to mirror**
|
|
|
|
```bash
|
|
cat turbo/generators/__tests__/core-package-events.e2e.test.ts
|
|
```
|
|
|
|
- [ ] **Step 2: Add audit entry to CORE_PACKAGE_GENERATORS + choices**
|
|
|
|
In `turbo/generators/config.ts`, find `CORE_PACKAGE_GENERATORS` and the `choices` list. Add:
|
|
|
|
```ts
|
|
// Add to choices array:
|
|
choices: ["realtime", "events", "trpc", "ui", "audit"],
|
|
|
|
// Add to CORE_PACKAGE_GENERATORS dispatch table:
|
|
audit: () => [
|
|
() => assertOptionalPackageNotPresent("core-audit"),
|
|
...emitTemplateTree("core-package/audit", "packages/core-audit"),
|
|
() => addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-audit"),
|
|
() => printAuditNextSteps(),
|
|
],
|
|
```
|
|
|
|
Also add the `printAuditNextSteps()` function alongside the other print functions:
|
|
|
|
```ts
|
|
function printAuditNextSteps(): string {
|
|
return [
|
|
"─────────────────────────────────────────────────────────────",
|
|
"@repo/core-audit scaffolded into packages/core-audit/.",
|
|
"",
|
|
"Manual wiring required (compliance-critical):",
|
|
"",
|
|
"1. Set AUDIT_PSEUDONYM_SALT env var (production REQUIRED):",
|
|
" export AUDIT_PSEUDONYM_SALT=\"$(openssl rand -hex 32)\"",
|
|
" Add to your deployment secrets manager.",
|
|
"",
|
|
"2. Mount the audit-logs Payload collection in packages/core-cms/src/payload.config.ts:",
|
|
" import { auditLogsCollection } from \"@repo/core-audit/collection\";",
|
|
" // collections: [..., auditLogsCollection],",
|
|
"",
|
|
"3. Mount the admin tRPC router in packages/core-api/src/root.ts:",
|
|
" import { auditRouter } from \"@repo/core-audit/api\";",
|
|
" // routers: { ..., audit: auditRouter },",
|
|
"",
|
|
"4. Bind audit in apps/web-next/src/server/bind-production.ts:",
|
|
" const { bindAudit } = await import(\"@repo/core-audit/di\");",
|
|
" const { auditLog } = bindAudit(sharedContainer, {",
|
|
" payloadConfig: resolvedConfig,",
|
|
" sinks: [\"payload\", \"stdout\"],",
|
|
" });",
|
|
"",
|
|
"5. Install user-collection hooks (recommended for DPA compliance):",
|
|
" In packages/auth/src/di/bind-production.ts, gate on ctx.auditLog:",
|
|
" if (ctx.auditLog) {",
|
|
" const { createAuditErasureHook, createAuditAfterReadHook } =",
|
|
" await import(\"@repo/core-audit/hooks\");",
|
|
" // wire onto users collection — see docs/guides/audit-and-compliance.md",
|
|
" }",
|
|
"",
|
|
"6. Set up a log shipper (Vector / Fluent Bit) to forward stdout JSON to",
|
|
" your aggregator. See docs/guides/audit-and-compliance.md for configs.",
|
|
"",
|
|
"7. Verify:",
|
|
" pnpm install",
|
|
" pnpm lint && pnpm typecheck && pnpm test",
|
|
" pnpm turbo boundaries",
|
|
"",
|
|
"See docs/guides/audit-and-compliance.md for the full guide.",
|
|
"─────────────────────────────────────────────────────────────",
|
|
].join("\n");
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create e2e test**
|
|
|
|
Create `turbo/generators/__tests__/core-package-audit.e2e.test.ts`:
|
|
|
|
```ts
|
|
import { describe, it, expect } from "vitest";
|
|
import { mkdtempSync, cpSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { execSync } from "node:child_process";
|
|
import { join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { computeSnapshot } from "../lib/snapshot.js";
|
|
import expectedSnapshot from "../__snapshots__/core-package/audit.snapshot.json" with { type: "json" };
|
|
|
|
const REPO_ROOT = resolve(fileURLToPath(import.meta.url), "..", "..", "..", "..");
|
|
|
|
function stripDep(pkgJsonPath: string, depName: string): void {
|
|
const raw = readFileSync(pkgJsonPath, "utf8");
|
|
const parsed = JSON.parse(raw) as Record<string, Record<string, string>>;
|
|
for (const section of ["dependencies", "devDependencies", "peerDependencies"] as const) {
|
|
if (parsed[section]?.[depName]) {
|
|
delete parsed[section][depName];
|
|
}
|
|
}
|
|
writeFileSync(pkgJsonPath, JSON.stringify(parsed, null, 2) + "\n");
|
|
}
|
|
|
|
describe("e2e: core-package audit", () => {
|
|
it(
|
|
"byte-identical reconstruction matches snapshot",
|
|
{ timeout: 120_000 },
|
|
() => {
|
|
const tmp = mkdtempSync(join(tmpdir(), "e2e-audit-"));
|
|
cpSync(REPO_ROOT, tmp, {
|
|
recursive: true,
|
|
filter: (src) =>
|
|
!src.includes("node_modules") &&
|
|
!src.includes(".turbo") &&
|
|
!src.includes(".git") &&
|
|
!src.includes("packages/core-audit"),
|
|
});
|
|
// Strip @repo/core-audit refs from package.jsons so install succeeds without the package
|
|
stripDep(join(tmp, "apps/web-next/package.json"), "@repo/core-audit");
|
|
execSync(`cd ${tmp} && pnpm install --silent`, { stdio: "inherit" });
|
|
execSync(`cd ${tmp} && pnpm turbo gen core-package --args audit`, { stdio: "inherit" });
|
|
const result = computeSnapshot(join(tmp, "packages/core-audit"));
|
|
expect(result).toEqual(expectedSnapshot);
|
|
},
|
|
);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Run the e2e test**
|
|
|
|
```bash
|
|
pnpm --filter @repo/turbo-generators test core-package-audit.e2e
|
|
```
|
|
Expected: PASS (~30-60s).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add turbo/generators/config.ts turbo/generators/__tests__/core-package-audit.e2e.test.ts
|
|
git commit -m "feat(generators): wire audit entry + e2e byte-identical reconstruction test"
|
|
```
|
|
|
|
### Task 6.5: Update doc surfaces
|
|
|
|
**Files:**
|
|
- Modify: `docs/architecture/template-tiers.md`
|
|
- Modify: `docs/scaffolding/core-package-generator.md`
|
|
- Modify: `CLAUDE.md`
|
|
- Modify: `AGENTS.md`
|
|
- Modify: `docs/architecture/data-flow-explainer.html`
|
|
- Modify: `README.md`
|
|
|
|
- [ ] **Step 1: template-tiers.md**
|
|
|
|
Open `docs/architecture/template-tiers.md`. Find the optional packages table. Add a row for core-audit:
|
|
|
|
```markdown
|
|
| core-audit | `pnpm turbo gen core-package audit` | ADR-018 | docs/guides/audit-and-compliance.md |
|
|
```
|
|
|
|
- [ ] **Step 2: scaffolding-doc**
|
|
|
|
Open `docs/scaffolding/core-package-generator.md`. Find the templates table and add audit:
|
|
|
|
```markdown
|
|
| `audit` | DPA-compliant audit logging (ADR-018) | Phase 7 |
|
|
```
|
|
|
|
- [ ] **Step 3: CLAUDE.md**
|
|
|
|
Find the Project Overview block in `CLAUDE.md` and update the optional packages list to include `core-audit`. Find the "Read first" section and add the audit guide:
|
|
|
|
```markdown
|
|
- `docs/guides/audit-and-compliance.md` — DPA-compliant audit logging cookbook (*requires `gen core-package audit`*)
|
|
```
|
|
|
|
- [ ] **Step 4: AGENTS.md**
|
|
|
|
Find the section that lists optional packages. Add `@repo/core-audit` (optional). Add audit row to the generator list if there is one.
|
|
|
|
- [ ] **Step 5: data-flow-explainer.html**
|
|
|
|
Open `docs/architecture/data-flow-explainer.html`. Find where realtime/events/trpc/ui are marked as conditional (dashed lines / optional tag). Add a similar audit-layer marker. If the explainer doesn't model audit yet, add a brief layer entry between "tRPC" and "Storage".
|
|
|
|
- [ ] **Step 6: README.md**
|
|
|
|
Find the Optional packages section. Add:
|
|
|
|
```bash
|
|
pnpm turbo gen core-package audit # DPA-compliant audit logging (ADR-018)
|
|
```
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add docs/architecture/template-tiers.md \
|
|
docs/scaffolding/core-package-generator.md \
|
|
CLAUDE.md AGENTS.md \
|
|
docs/architecture/data-flow-explainer.html \
|
|
README.md
|
|
git commit -m "docs: surface core-audit as 5th optional package across discovery points"
|
|
```
|
|
|
|
### Task 6.6: Final verification gate
|
|
|
|
- [ ] **Step 1: Run all gates from repo root**
|
|
|
|
```bash
|
|
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries
|
|
```
|
|
Expected: all green.
|
|
|
|
- [ ] **Step 2: Confirm e2e test passes**
|
|
|
|
```bash
|
|
pnpm --filter @repo/turbo-generators test
|
|
```
|
|
Expected: 5 byte-identical reconstruction tests pass (realtime, events, trpc, ui, audit).
|
|
|
|
- [ ] **Step 3: No commit — verification only**
|
|
|
|
Plan complete.
|
|
|
|
---
|
|
|
|
## Notes for the executing agent
|
|
|
|
- Phases 1 → 6 are sequenced. Don't start a phase until the previous one's gates are green.
|
|
- The most subtle piece is Task 4.3's wrapper test update — `instanceof MultiSinkAuditLog` no longer matches because the returned `auditLog` is a `TraceIdEnrichingAuditLog`. The fix is to assert on `.inner` (or use a typed accessor); the test code in Task 4.3 step 2 spells this out.
|
|
- Phase 6's e2e test (Task 6.4) requires the byte-identical snapshot generated in Task 6.3 to match exactly. If the snapshot was generated AFTER any changes to `packages/core-audit/` (Phases 1-5 should be done first), this is fine. If you regenerate Phase 1-5 between snapshot generation and the e2e run, snapshots won't match. Generate snapshot LAST.
|
|
- The `printAuditNextSteps()` function in Task 6.4 has 7 manual wiring steps. They're long but each is concrete (copy-paste-ready code blocks). Mirror the existing `printRealtimeNextSteps()` / `printEventsNextSteps()` patterns for formatting.
|
|
- Auth feature is NOT modified by this plan. Audit's user-collection hook installation is documented in the generator's next-steps; downstream consumers wire it manually after scaffolding. This keeps core-audit truly optional.
|
|
- Commit cadence: ~25-30 commits across the six phases. Each commit should leave the repo in a green-gate state.
|