diff --git a/turbo/generators/__snapshots__/core-package/audit.snapshot.json b/turbo/generators/__snapshots__/core-package/audit.snapshot.json new file mode 100644 index 0000000..1c7a013 --- /dev/null +++ b/turbo/generators/__snapshots__/core-package/audit.snapshot.json @@ -0,0 +1,134 @@ +[ + { + "path": "AGENTS.md", + "sha256": "65b3fe18cf85c2fb23fe88b5d7bda102316eccee7539c0d326947eb1c0b9a645" + }, + { + "path": "eslint.config.js", + "sha256": "64a85c158e386417d855793d256b9c59be38635ce2febcb5a6a15b17f4143745" + }, + { + "path": "package.json", + "sha256": "8cab25d20ec35d1fea7a219ae826eef968ffcc54a023e5e60c1e14aa99459361" + }, + { + "path": "src/audit-log.interface.ts", + "sha256": "f316595a17c9b6805d0601fd3b6301ab2d4d8959b11d0690d58df7f127baebe7" + }, + { + "path": "src/audit-logs-collection.test.ts", + "sha256": "7fc919718cd0fe753e03ba7abc9ef8d423b678da27503ffb6ff66dc4ea1916f0" + }, + { + "path": "src/audit-logs-collection.ts", + "sha256": "398f868ba38ad27a7393e0b73ccddec4e8a145aec3c055a403a7973e484ddc3f" + }, + { + "path": "src/di/bind-audit.test.ts", + "sha256": "fbeb4dfdc4fd791f1f0e1ccc6427bd1681994f1726a62e87c1b228100ad2d8bb" + }, + { + "path": "src/di/bind-audit.ts", + "sha256": "cecc80336488b7af62a915963e9fb4a2a6a0e0a31c5aa178150836b4cb504789" + }, + { + "path": "src/di/symbols.ts", + "sha256": "00dfd799408966c7cf7ec0bb2f9c38c2bce3dd4beabd7b0cca66043b51051483" + }, + { + "path": "src/hooks/audit-after-read-hook.test.ts", + "sha256": "e83b6a83c7ef21383ac9202ad66d859fe9cea1c065903bf6eb36ebefac60ab59" + }, + { + "path": "src/hooks/audit-after-read-hook.ts", + "sha256": "39c027bde689ecaa5ce06101ae56b30389123ae8589230a32d6cbbaabeb9a28b" + }, + { + "path": "src/hooks/audit-erasure-hook.test.ts", + "sha256": "2ac009a983b203eade0790ec6ff95c6cb8d7983cbbc1851ae6ec0efd35573838" + }, + { + "path": "src/hooks/audit-erasure-hook.ts", + "sha256": "aa2cd2264eb65afc76cb81079174a8f7e7c4e4f884fba87f4b72463a5623bae5" + }, + { + "path": "src/hooks/index.ts", + "sha256": "dfeeba5f12da914d270b1f3ec87541c91435dcec7e9c1f5542148d7bcc288f8b" + }, + { + "path": "src/index.ts", + "sha256": "677726cbf5e1f17f0915e8141dbf158ecc87e12342dc188d68154496e333b06b" + }, + { + "path": "src/integrations/api/procedures.ts", + "sha256": "f142ac0e40459a8f652a90b06fa6315caac0187b215a1b28f12fcb40bd50f65d" + }, + { + "path": "src/integrations/api/router.test.ts", + "sha256": "309a9643d88fc6dd2fd2db7defdcf3e826210a9f30c4ba5eb60f9a4074763a45" + }, + { + "path": "src/integrations/api/router.ts", + "sha256": "6c6ef05ef723776cb6a15ab13ef8b9f5f27c4de81a7e09b6eb299c303bfca841" + }, + { + "path": "src/multi-sink-audit-log.test.ts", + "sha256": "7b63a2dc865bf0020aef97b052f3c4b69a35a471365ee67b1a3ba88b8728a547" + }, + { + "path": "src/multi-sink-audit-log.ts", + "sha256": "010045b2c54f0799c4096eb90c8d25ebcb7281e25fe5b1985d9426779f99a5fb" + }, + { + "path": "src/noop-audit-log.test.ts", + "sha256": "161089a8fdb5c9c2eddae6006ac38cc0d18da82e9f28b6c5acd42b4660876ffb" + }, + { + "path": "src/noop-audit-log.ts", + "sha256": "b0421ed50916af095740073ae358d8c7b303af6e8639499563bf51f571e81909" + }, + { + "path": "src/payload-audit-log.test.ts", + "sha256": "fc328519888a850b99cc6e0c9abc0c3888b03573cd5f34a3219ebd889d9b7f10" + }, + { + "path": "src/payload-audit-log.ts", + "sha256": "e486a18ecb0220596aea0babcb42d3d85e20c9bae630edffc039be89c153beb5" + }, + { + "path": "src/pseudonymize.test.ts", + "sha256": "bfd0af626ae461ad1a481b99a42053ee75f00ac92c2463b9872e875b178d76ca" + }, + { + "path": "src/pseudonymize.ts", + "sha256": "8139dccdf79ac6930be44c5b462965cebc59c9cfa4fbde841c7ed9acf1fab1ba" + }, + { + "path": "src/stdout-json-audit-log.test.ts", + "sha256": "9b4d659a374f5bbd3bb62927afb564e852975dd79a38deace3ca18b027b79735" + }, + { + "path": "src/stdout-json-audit-log.ts", + "sha256": "6cbc38b1d746e5cd2fce2ae0f3a28c430c3131ce4e09a124c30da74223c39959" + }, + { + "path": "src/trace-id-enriching-audit-log.test.ts", + "sha256": "d9259dd13a8db1e6c3bf75d9441001031c3bb26495f9f388c8c93f85ec67de22" + }, + { + "path": "src/trace-id-enriching-audit-log.ts", + "sha256": "1a80e12cbec2e13a5d31545b8641bf777d57a2fc6fbabf2c3cd92917c74f5141" + }, + { + "path": "tsconfig.json", + "sha256": "0ed0e183a0842acb42a8c712a9dad0217b4612a9ae17e1f3a3f61b638d204147" + }, + { + "path": "turbo.json", + "sha256": "ae544305202c960399110a31fe976e2c38577956c5aa738989f5d885f156d130" + }, + { + "path": "vitest.config.ts", + "sha256": "cc7a9e6aba7cbe81af665c3e1bf50f535f77a2b950db8ccc62775c7b5d380df7" + } +] diff --git a/turbo/generators/templates/core-package/audit/AGENTS.md.hbs b/turbo/generators/templates/core-package/audit/AGENTS.md.hbs new file mode 100644 index 0000000..052f88f --- /dev/null +++ b/turbo/generators/templates/core-package/audit/AGENTS.md.hbs @@ -0,0 +1,28 @@ +# @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. diff --git a/turbo/generators/templates/core-package/audit/eslint.config.js.hbs b/turbo/generators/templates/core-package/audit/eslint.config.js.hbs new file mode 100644 index 0000000..7440d8f --- /dev/null +++ b/turbo/generators/templates/core-package/audit/eslint.config.js.hbs @@ -0,0 +1,3 @@ +import baseConfig from "@repo/core-eslint/base"; + +export default baseConfig; diff --git a/turbo/generators/templates/core-package/audit/package.json.hbs b/turbo/generators/templates/core-package/audit/package.json.hbs new file mode 100644 index 0000000..013f356 --- /dev/null +++ b/turbo/generators/templates/core-package/audit/package.json.hbs @@ -0,0 +1,46 @@ +{ + "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": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.55.0", + "@opentelemetry/context-async-hooks": "^1.28.0", + "@opentelemetry/sdk-trace-base": "^1.27.0", + "@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" + } +} diff --git a/turbo/generators/templates/core-package/audit/src/audit-log.interface.ts.hbs b/turbo/generators/templates/core-package/audit/src/audit-log.interface.ts.hbs new file mode 100644 index 0000000..a4b8abd --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/audit-log.interface.ts.hbs @@ -0,0 +1,22 @@ +import type { AuditLogProtocol } from "@repo/core-shared/di/bind-protocols"; +import type { 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 — inherited from protocol + eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise; +} + +// Re-export AuditEntry for convenience (so consumers don't always need +// to dual-import from @repo/core-shared/audit). +export type { AuditEntry }; diff --git a/turbo/generators/templates/core-package/audit/src/audit-logs-collection.test.ts.hbs b/turbo/generators/templates/core-package/audit/src/audit-logs-collection.test.ts.hbs new file mode 100644 index 0000000..cd105e1 --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/audit-logs-collection.test.ts.hbs @@ -0,0 +1,47 @@ +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 boolean) | undefined>; + 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); + }); +}); diff --git a/turbo/generators/templates/core-package/audit/src/audit-logs-collection.ts.hbs b/turbo/generators/templates/core-package/audit/src/audit-logs-collection.ts.hbs new file mode 100644 index 0000000..7e6123b --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/audit-logs-collection.ts.hbs @@ -0,0 +1,82 @@ +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" }, + ], +}; diff --git a/turbo/generators/templates/core-package/audit/src/di/bind-audit.test.ts.hbs b/turbo/generators/templates/core-package/audit/src/di/bind-audit.test.ts.hbs new file mode 100644 index 0000000..9cc9b65 --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/di/bind-audit.test.ts.hbs @@ -0,0 +1,58 @@ +import "reflect-metadata"; +import { describe, it, expect } 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 { TraceIdEnrichingAuditLog } from "../trace-id-enriching-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(AUDIT_SYMBOLS.IAuditLog); + expect(auditLog).toBeInstanceOf(TraceIdEnrichingAuditLog); + expect((auditLog as unknown as { inner: unknown }).inner).toBeInstanceOf(MultiSinkAuditLog); + }); + + it("returns StdoutJsonAuditLog alone when payloadConfig omitted + default sinks", () => { + const container = new Container(); + bindAudit(container, {}); + const auditLog = container.get(AUDIT_SYMBOLS.IAuditLog); + expect(auditLog).toBeInstanceOf(TraceIdEnrichingAuditLog); + expect((auditLog as unknown as { inner: unknown }).inner).toBeInstanceOf(StdoutJsonAuditLog); + }); + + it("returns NoopAuditLog when sinks=[]", () => { + const container = new Container(); + bindAudit(container, { sinks: [] }); + const auditLog = container.get(AUDIT_SYMBOLS.IAuditLog); + expect(auditLog).toBeInstanceOf(TraceIdEnrichingAuditLog); + expect((auditLog as unknown as { inner: unknown }).inner).toBeInstanceOf(NoopAuditLog); + }); + + it("returns PayloadAuditLog when sinks=['payload'] only", () => { + const container = new Container(); + bindAudit(container, { payloadConfig: {} as never, sinks: ["payload"] }); + const auditLog = container.get(AUDIT_SYMBOLS.IAuditLog); + expect(auditLog).toBeInstanceOf(TraceIdEnrichingAuditLog); + expect((auditLog as unknown as { inner: unknown }).inner).toBeInstanceOf(PayloadAuditLog); + }); + + it("validates AUDIT_PSEUDONYM_SALT in production", () => { + const env = process.env as Record; + const oldEnv = env["NODE_ENV"]; + const oldSalt = env["AUDIT_PSEUDONYM_SALT"]; + env["NODE_ENV"] = "production"; + delete env["AUDIT_PSEUDONYM_SALT"]; + expect(() => bindAudit(new Container(), { sinks: ["stdout"] })).toThrow( + /AUDIT_PSEUDONYM_SALT/, + ); + env["NODE_ENV"] = oldEnv; + if (oldSalt) env["AUDIT_PSEUDONYM_SALT"] = oldSalt; + }); +}); diff --git a/turbo/generators/templates/core-package/audit/src/di/bind-audit.ts.hbs b/turbo/generators/templates/core-package/audit/src/di/bind-audit.ts.hbs new file mode 100644 index 0000000..f4154bb --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/di/bind-audit.ts.hbs @@ -0,0 +1,66 @@ +import "reflect-metadata"; +import type { Container } from "inversify"; +import { getPayload as _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"; +import { TraceIdEnrichingAuditLog } from "../trace-id-enriching-audit-log"; + +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. + * + * The returned auditLog is wrapped in TraceIdEnrichingAuditLog (Phase 4) + * so all sinks receive AuditEntry.correlationId auto-populated from the + * active OTel span. The inner sink/fan-out is accessible via `.inner`. + */ +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) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sinks.push(new PayloadAuditLog(opts.payloadConfig, _getPayload as any)); + } + if (sinkList.includes("stdout")) { + sinks.push(new StdoutJsonAuditLog()); + } + + const inner: IAuditLog = + sinks.length > 1 ? new MultiSinkAuditLog(sinks) + : sinks.length === 1 ? sinks[0]! + : new NoopAuditLog(); + const auditLog: IAuditLog = new TraceIdEnrichingAuditLog(inner); + + if (container.isBound(AUDIT_SYMBOLS.IAuditLog)) { + container.unbind(AUDIT_SYMBOLS.IAuditLog); + } + container.bind(AUDIT_SYMBOLS.IAuditLog).toConstantValue(auditLog); + + return { auditLog }; +} diff --git a/turbo/generators/templates/core-package/audit/src/di/symbols.ts.hbs b/turbo/generators/templates/core-package/audit/src/di/symbols.ts.hbs new file mode 100644 index 0000000..badb48a --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/di/symbols.ts.hbs @@ -0,0 +1,3 @@ +export const AUDIT_SYMBOLS = { + IAuditLog: Symbol.for("core-audit:IAuditLog"), +} as const; diff --git a/turbo/generators/templates/core-package/audit/src/hooks/audit-after-read-hook.test.ts.hbs b/turbo/generators/templates/core-package/audit/src/hooks/audit-after-read-hook.test.ts.hbs new file mode 100644 index 0000000..fde4fe1 --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/hooks/audit-after-read-hook.test.ts.hbs @@ -0,0 +1,103 @@ +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(); + }); +}); diff --git a/turbo/generators/templates/core-package/audit/src/hooks/audit-after-read-hook.ts.hbs b/turbo/generators/templates/core-package/audit/src/hooks/audit-after-read-hook.ts.hbs new file mode 100644 index 0000000..bfa759f --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/hooks/audit-after-read-hook.ts.hbs @@ -0,0 +1,105 @@ +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 async ({ 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 }; + 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 }; + const ua = r.headers?.["user-agent"]; + if (!ua) return undefined; + return Array.isArray(ua) ? ua[0] : ua; +} diff --git a/turbo/generators/templates/core-package/audit/src/hooks/audit-erasure-hook.test.ts.hbs b/turbo/generators/templates/core-package/audit/src/hooks/audit-erasure-hook.test.ts.hbs new file mode 100644 index 0000000..0b7fb4c --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/hooks/audit-erasure-hook.test.ts.hbs @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from "vitest"; +import { createAuditErasureHook } from "./audit-erasure-hook"; +import type { IAuditLog } from "../audit-log.interface"; + +function makeAuditLog(): IAuditLog { + return { + record: vi.fn().mockResolvedValue(undefined), + eraseSubject: vi.fn().mockResolvedValue(undefined), + }; +} + +/** Minimal CollectionAfterDeleteHook args shape (only `doc` matters here). */ +function hookArgs(id: unknown) { + return { + doc: { id }, + req: {} as never, + id: String(id), + collection: {} as never, + context: {}, + }; +} + +describe("createAuditErasureHook", () => { + it("defaults to 'pseudonymize' mode", async () => { + const auditLog = makeAuditLog(); + const hook = createAuditErasureHook({ auditLog }); + await hook(hookArgs("user_1") as never); + expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_1", "pseudonymize"); + }); + + it("respects explicit mode='delete'", async () => { + const auditLog = makeAuditLog(); + const hook = createAuditErasureHook({ auditLog, mode: "delete" }); + await hook(hookArgs("user_2") as never); + expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_2", "delete"); + }); + + it("coerces numeric id to string", async () => { + const auditLog = makeAuditLog(); + const hook = createAuditErasureHook({ auditLog }); + await hook(hookArgs(42) as never); + expect(auditLog.eraseSubject).toHaveBeenCalledWith("42", "pseudonymize"); + }); + + it("skips when doc.id is undefined", async () => { + const auditLog = makeAuditLog(); + const hook = createAuditErasureHook({ auditLog }); + await hook(hookArgs(undefined) as never); + expect(auditLog.eraseSubject).not.toHaveBeenCalled(); + }); + + it("skips when doc.id is null", async () => { + const auditLog = makeAuditLog(); + const hook = createAuditErasureHook({ auditLog }); + await hook(hookArgs(null) as never); + expect(auditLog.eraseSubject).not.toHaveBeenCalled(); + }); + + it("skips when doc.id is an object", async () => { + const auditLog = makeAuditLog(); + const hook = createAuditErasureHook({ auditLog }); + await hook(hookArgs({ nested: true }) as never); + expect(auditLog.eraseSubject).not.toHaveBeenCalled(); + }); +}); diff --git a/turbo/generators/templates/core-package/audit/src/hooks/audit-erasure-hook.ts.hbs b/turbo/generators/templates/core-package/audit/src/hooks/audit-erasure-hook.ts.hbs new file mode 100644 index 0000000..c6001ed --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/hooks/audit-erasure-hook.ts.hbs @@ -0,0 +1,38 @@ +import type { CollectionAfterDeleteHook } from "payload"; +import type { IAuditLog } from "../audit-log.interface"; + +export type AuditErasureHookOpts = { + /** The audit log impl that will perform the erasure. */ + auditLog: IAuditLog; + /** + * Erasure mode. Defaults to "pseudonymize" — the softer option that + * retains the audit trail shape while removing PII linkage. Use + * "delete" only when the data-subject specifically requests hard removal. + */ + mode?: "pseudonymize" | "delete"; +}; + +/** + * Payload `afterDelete` hook factory for GDPR erasure. + * + * Wire this on any collection whose `id` doubles as an audit subject + * (e.g., the users collection). When Payload deletes a document, the + * hook calls `auditLog.eraseSubject(String(doc.id), mode)`, removing + * or pseudonymizing all audit entries recorded for that actor. + * + * The hook has no schema-specific knowledge — it works on any collection + * that stores the subject identifier as its document `id`. + * + * Non-string, non-numeric ids are silently skipped (safe guard against + * undefined/null that Payload may produce in edge cases). + */ +export function createAuditErasureHook( + opts: AuditErasureHookOpts, +): CollectionAfterDeleteHook { + const mode = opts.mode ?? "pseudonymize"; + return async ({ doc }) => { + if (typeof doc.id === "string" || typeof doc.id === "number") { + await opts.auditLog.eraseSubject(String(doc.id), mode); + } + }; +} diff --git a/turbo/generators/templates/core-package/audit/src/hooks/index.ts.hbs b/turbo/generators/templates/core-package/audit/src/hooks/index.ts.hbs new file mode 100644 index 0000000..11dcd14 --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/hooks/index.ts.hbs @@ -0,0 +1,8 @@ +export { + createAuditErasureHook, + type AuditErasureHookOpts, +} from "./audit-erasure-hook"; +export { + createAuditAfterReadHook, + type AuditAfterReadHookOpts, +} from "./audit-after-read-hook"; diff --git a/turbo/generators/templates/core-package/audit/src/index.ts.hbs b/turbo/generators/templates/core-package/audit/src/index.ts.hbs new file mode 100644 index 0000000..a76b85e --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/index.ts.hbs @@ -0,0 +1,30 @@ +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 { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log"; +export { AUDIT_SYMBOLS } from "./di/symbols"; +// Phase 3 — GDPR erasure +export { pseudonymize } from "./pseudonymize"; +export { + createAuditErasureHook, + type AuditErasureHookOpts, +} from "./hooks/audit-erasure-hook"; +// Phase 5 — VIEW capture +export { + createAuditAfterReadHook, + type AuditAfterReadHookOpts, +} from "./hooks"; +export { + createAuditRouter, + auditRouter, + type AuditRouter, +} from "./integrations/api/router"; +export { + auditProcedure, + type AdminTrpcUser, +} from "./integrations/api/procedures"; diff --git a/turbo/generators/templates/core-package/audit/src/integrations/api/procedures.ts.hbs b/turbo/generators/templates/core-package/audit/src/integrations/api/procedures.ts.hbs new file mode 100644 index 0000000..a80e4d1 --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/integrations/api/procedures.ts.hbs @@ -0,0 +1,43 @@ +import { TRPCError } from "@trpc/server"; +import { t } from "@repo/core-shared/trpc/init"; +import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; + +/** + * The minimum user shape that the adminOnly middleware expects to find on `ctx`. + * Apps must include this field when creating their tRPC context for requests + * that may reach admin procedures. Unauthenticated requests leave `user` + * undefined, which the middleware treats as non-admin. + */ +export type AdminTrpcUser = { + roles: string[]; +}; + +/** + * Middleware that blocks non-admin callers. + * + * Reads `ctx.user?.roles` from the tRPC context. Throws FORBIDDEN if the + * user is absent or lacks the "admin" role. Apps that mount the auditRouter + * must set `ctx.user` with the authenticated user's roles. + */ +const adminOnly = t.middleware(({ ctx, next }) => { + const user = (ctx as { user?: AdminTrpcUser }).user; + if (!user?.roles.includes("admin")) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Admin role required", + }); + } + return next({ ctx: { ...ctx, user } }); +}); + +/** + * Base procedure for all audit admin routes. + * + * - `adminOnly` middleware gates every mutation/query. + * - `defineErrorMiddleware([])` — no audit-specific domain errors need tRPC + * mapping; the FORBIDDEN thrown by `adminOnly` is a plain TRPCError and + * propagates unchanged. + */ +export const auditProcedure = t.procedure + .use(adminOnly) + .use(defineErrorMiddleware([])); diff --git a/turbo/generators/templates/core-package/audit/src/integrations/api/router.test.ts.hbs b/turbo/generators/templates/core-package/audit/src/integrations/api/router.test.ts.hbs new file mode 100644 index 0000000..3f3e755 --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/integrations/api/router.test.ts.hbs @@ -0,0 +1,93 @@ +import { describe, it, expect, vi } from "vitest"; +import { createAuditRouter } from "./router"; +import type { IAuditLog } from "../../audit-log.interface"; +import type { AdminTrpcUser } from "./procedures"; + +/** + * Minimal harness: create a tRPC caller directly from the router so we + * don't need a real HTTP layer. + */ +function makeCallerWithUser( + auditLog: IAuditLog, + user?: AdminTrpcUser, +) { + const router = createAuditRouter(auditLog); + // Use the tRPC caller factory to invoke mutations directly in tests. + return router.createCaller({ user } as Record); +} + +function makeAuditLog(): IAuditLog { + return { + record: vi.fn().mockResolvedValue(undefined), + eraseSubject: vi.fn().mockResolvedValue(undefined), + }; +} + +describe("auditRouter.eraseSubject", () => { + it("throws FORBIDDEN when ctx.user is absent", async () => { + const auditLog = makeAuditLog(); + const caller = makeCallerWithUser(auditLog, undefined); + + await expect( + caller.eraseSubject({ actorId: "user_1", mode: "pseudonymize" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + expect(auditLog.eraseSubject).not.toHaveBeenCalled(); + }); + + it("throws FORBIDDEN when user lacks admin role", async () => { + const auditLog = makeAuditLog(); + const caller = makeCallerWithUser(auditLog, { roles: ["editor", "viewer"] }); + + await expect( + caller.eraseSubject({ actorId: "user_1", mode: "pseudonymize" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + expect(auditLog.eraseSubject).not.toHaveBeenCalled(); + }); + + it("calls eraseSubject with pseudonymize mode for an admin user", async () => { + const auditLog = makeAuditLog(); + const caller = makeCallerWithUser(auditLog, { roles: ["admin"] }); + + const result = await caller.eraseSubject({ + actorId: "user_1", + mode: "pseudonymize", + }); + + expect(result).toEqual({ ok: true }); + expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_1", "pseudonymize"); + }); + + it("calls eraseSubject with delete mode for an admin user", async () => { + const auditLog = makeAuditLog(); + const caller = makeCallerWithUser(auditLog, { roles: ["admin"] }); + + const result = await caller.eraseSubject({ + actorId: "user_2", + mode: "delete", + }); + + expect(result).toEqual({ ok: true }); + expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_2", "delete"); + }); + + it("defaults mode to 'pseudonymize' when not provided", async () => { + const auditLog = makeAuditLog(); + const caller = makeCallerWithUser(auditLog, { roles: ["admin"] }); + + // mode has a .default("pseudonymize") in the schema + await caller.eraseSubject({ actorId: "user_3", mode: "pseudonymize" }); + + expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_3", "pseudonymize"); + }); + + it("rejects empty actorId (schema validation)", async () => { + const auditLog = makeAuditLog(); + const caller = makeCallerWithUser(auditLog, { roles: ["admin"] }); + + await expect( + caller.eraseSubject({ actorId: "", mode: "pseudonymize" }), + ).rejects.toThrow(); + }); +}); diff --git a/turbo/generators/templates/core-package/audit/src/integrations/api/router.ts.hbs b/turbo/generators/templates/core-package/audit/src/integrations/api/router.ts.hbs new file mode 100644 index 0000000..1026e5b --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/integrations/api/router.ts.hbs @@ -0,0 +1,54 @@ +import { z } from "zod"; +import { t } from "@repo/core-shared/trpc/init"; +import type { IAuditLog } from "../../audit-log.interface"; +import { auditProcedure } from "./procedures"; + +/** + * Creates the audit admin tRPC router. + * + * The `auditLog` parameter is captured at router-creation time. Apps that + * mount this router must pass the `IAuditLog` impl returned by `bindAudit`. + * + * @example + * ```ts + * const { auditLog } = bindAudit(container, { payloadConfig, sinks: ["payload", "stdout"] }); + * const appRouter = t.router({ ..., audit: createAuditRouter(auditLog) }); + * ``` + */ +export function createAuditRouter(auditLog: IAuditLog) { + return t.router({ + eraseSubject: auditProcedure + .input( + z + .object({ + actorId: z.string().min(1), + mode: z.enum(["pseudonymize", "delete"]).default("pseudonymize"), + }) + .strict(), + ) + .mutation(async ({ input }) => { + await auditLog.eraseSubject(input.actorId, input.mode); + return { ok: true as const }; + }), + }); +} + +/** + * Convenience singleton for projects that have a single audit log instance. + * Most callers should use `createAuditRouter` and pass the IAuditLog explicitly. + * This export is a stub that throws at call time if auditLog has not been + * provided — it exists for type inference purposes (`AuditRouter`). + */ +export const auditRouter = createAuditRouter( + new Proxy({} as IAuditLog, { + get(_target, prop) { + if (prop === "then") return undefined; // not a Promise + throw new Error( + `auditRouter singleton used without providing an IAuditLog. ` + + `Use createAuditRouter(auditLog) instead.`, + ); + }, + }), +); + +export type AuditRouter = ReturnType; diff --git a/turbo/generators/templates/core-package/audit/src/multi-sink-audit-log.test.ts.hbs b/turbo/generators/templates/core-package/audit/src/multi-sink-audit-log.test.ts.hbs new file mode 100644 index 0000000..63c9961 --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/multi-sink-audit-log.test.ts.hbs @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from "vitest"; +import { MultiSinkAuditLog } from "./multi-sink-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"]); + }); +}); diff --git a/turbo/generators/templates/core-package/audit/src/multi-sink-audit-log.ts.hbs b/turbo/generators/templates/core-package/audit/src/multi-sink-audit-log.ts.hbs new file mode 100644 index 0000000..0be9b36 --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/multi-sink-audit-log.ts.hbs @@ -0,0 +1,45 @@ +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 { + 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 { + 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"); + } +} diff --git a/turbo/generators/templates/core-package/audit/src/noop-audit-log.test.ts.hbs b/turbo/generators/templates/core-package/audit/src/noop-audit-log.test.ts.hbs new file mode 100644 index 0000000..68cdd69 --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/noop-audit-log.test.ts.hbs @@ -0,0 +1,29 @@ +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(); + }); +}); diff --git a/turbo/generators/templates/core-package/audit/src/noop-audit-log.ts.hbs b/turbo/generators/templates/core-package/audit/src/noop-audit-log.ts.hbs new file mode 100644 index 0000000..3d9e5b1 --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/noop-audit-log.ts.hbs @@ -0,0 +1,11 @@ +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 { + // intentional no-op + } + async eraseSubject(_actorId: string, _mode: "pseudonymize" | "delete"): Promise { + // intentional no-op + } +} diff --git a/turbo/generators/templates/core-package/audit/src/payload-audit-log.test.ts.hbs b/turbo/generators/templates/core-package/audit/src/payload-audit-log.test.ts.hbs new file mode 100644 index 0000000..e37e5f4 --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/payload-audit-log.test.ts.hbs @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach, afterEach } 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 }; + 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"); + }); +}); + +describe("PayloadAuditLog.eraseSubject", () => { + const originalSalt = process.env["AUDIT_PSEUDONYM_SALT"]; + + beforeEach(() => { + process.env["AUDIT_PSEUDONYM_SALT"] = "test-salt-erase"; + }); + + afterEach(() => { + if (originalSalt === undefined) { + delete process.env["AUDIT_PSEUDONYM_SALT"]; + } else { + process.env["AUDIT_PSEUDONYM_SALT"] = originalSalt; + } + }); + + it("mode='delete' calls payload.delete with the correct where clause + overrideAccess", async () => { + const mockDelete = vi.fn().mockResolvedValue({}); + const mockGetPayload = vi.fn().mockResolvedValue({ delete: mockDelete }); + 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: Record; + overrideAccess: boolean; + }; + expect(call.collection).toBe("audit-logs"); + expect(call.where).toEqual({ actorId: { equals: "user_1" } }); + expect(call.overrideAccess).toBe(true); + }); + + it("mode='pseudonymize' finds matching docs and updates each actorId to the pseudonym", async () => { + const mockFind = vi.fn().mockResolvedValue({ + docs: [{ id: "doc_a" }, { id: "doc_b" }], + }); + const mockUpdate = vi.fn().mockResolvedValue({}); + const mockGetPayload = vi.fn().mockResolvedValue({ + find: mockFind, + update: mockUpdate, + }); + const log = new PayloadAuditLog({} as never, mockGetPayload); + + await log.eraseSubject("user_1", "pseudonymize"); + + // find must use overrideAccess + limit=10_000 + const findCall = mockFind.mock.calls[0]![0] as { + collection: string; + where: Record; + limit: number; + overrideAccess: boolean; + }; + expect(findCall.collection).toBe("audit-logs"); + expect(findCall.where).toEqual({ actorId: { equals: "user_1" } }); + expect(findCall.limit).toBe(10_000); + expect(findCall.overrideAccess).toBe(true); + + // update called for each doc + expect(mockUpdate).toHaveBeenCalledTimes(2); + const updateCalls = mockUpdate.mock.calls as Array< + [{ collection: string; id: string; data: Record; overrideAccess: boolean }] + >; + expect(updateCalls[0]![0].id).toBe("doc_a"); + expect(updateCalls[1]![0].id).toBe("doc_b"); + + // both updates replace actorId with the same pseudonym + const pseudonym = updateCalls[0]![0].data["actorId"] as string; + expect(pseudonym).toMatch(/^erased-[0-9a-f]{16}$/); + expect(updateCalls[1]![0].data["actorId"]).toBe(pseudonym); + + // overrideAccess bypasses the append-only rule + expect(updateCalls[0]![0].overrideAccess).toBe(true); + }); + + it("mode='pseudonymize' with no matching docs does not call update", async () => { + const mockFind = vi.fn().mockResolvedValue({ docs: [] }); + const mockUpdate = vi.fn(); + const mockGetPayload = vi.fn().mockResolvedValue({ find: mockFind, update: mockUpdate }); + const log = new PayloadAuditLog({} as never, mockGetPayload); + + await log.eraseSubject("unknown_user", "pseudonymize"); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/turbo/generators/templates/core-package/audit/src/payload-audit-log.ts.hbs b/turbo/generators/templates/core-package/audit/src/payload-audit-log.ts.hbs new file mode 100644 index 0000000..ca64543 --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/payload-audit-log.ts.hbs @@ -0,0 +1,103 @@ +import type { SanitizedConfig } from "payload"; +import type { AuditEntry } from "@repo/core-shared/audit"; +import type { IAuditLog } from "./audit-log.interface"; +import { pseudonymize } from "./pseudonymize"; + +type GetPayload = (args: { config: SanitizedConfig }) => Promise<{ + create: (args: { collection: string; data: Record }) => Promise; + find: (args: { + collection: string; + where: Record; + limit: number; + overrideAccess: true; + }) => Promise<{ docs: Array<{ id: string | number }> }>; + update: (args: { + collection: string; + id: string | number; + data: Record; + overrideAccess: true; + }) => Promise; + delete: (args: { + collection: string; + where: Record; + overrideAccess: true; + }) => Promise; +}>; + +/** + * 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 { + 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 { + const payload = await this.getPayload({ config: this.config }); + + if (mode === "delete") { + await payload.delete({ + collection: "audit-logs", + where: { actorId: { equals: actorId } }, + overrideAccess: true, + }); + return; + } + + // mode === "pseudonymize" + // Fetch all matching docs. Limit is 10_000 — a subject with more than + // 10k audit entries will not have all entries pseudonymized in one call. + // This is an accepted v1 limitation; callers may loop if needed. + const { docs } = await payload.find({ + collection: "audit-logs", + where: { actorId: { equals: actorId } }, + limit: 10_000, + overrideAccess: true, + }); + + const pseudonym = pseudonymize(actorId); + for (const doc of docs) { + await payload.update({ + collection: "audit-logs", + id: doc.id, + data: { actorId: pseudonym }, + overrideAccess: true, + }); + } + } +} diff --git a/turbo/generators/templates/core-package/audit/src/pseudonymize.test.ts.hbs b/turbo/generators/templates/core-package/audit/src/pseudonymize.test.ts.hbs new file mode 100644 index 0000000..66d737c --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/pseudonymize.test.ts.hbs @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { pseudonymize } from "./pseudonymize"; + +describe("pseudonymize", () => { + const originalSalt = process.env["AUDIT_PSEUDONYM_SALT"]; + + beforeEach(() => { + process.env["AUDIT_PSEUDONYM_SALT"] = "test-salt-1"; + }); + + afterEach(() => { + if (originalSalt === undefined) { + delete process.env["AUDIT_PSEUDONYM_SALT"]; + } else { + process.env["AUDIT_PSEUDONYM_SALT"] = originalSalt; + } + }); + + it("returns a string prefixed with 'erased-'", () => { + const result = pseudonymize("user_42"); + expect(result).toMatch(/^erased-/); + }); + + it("produces exactly 16 hex chars after the prefix", () => { + const result = pseudonymize("user_42"); + const hex = result.slice("erased-".length); + expect(hex).toHaveLength(16); + expect(hex).toMatch(/^[0-9a-f]+$/); + }); + + it("is deterministic — same salt + actorId always yields the same token", () => { + const a = pseudonymize("user_42"); + const b = pseudonymize("user_42"); + expect(a).toBe(b); + }); + + it("differs when actorId differs (same salt)", () => { + const a = pseudonymize("user_42"); + const b = pseudonymize("user_99"); + expect(a).not.toBe(b); + }); + + it("differs when the salt changes", () => { + const withSalt1 = pseudonymize("user_42"); + + process.env["AUDIT_PSEUDONYM_SALT"] = "test-salt-2"; + const withSalt2 = pseudonymize("user_42"); + + expect(withSalt1).not.toBe(withSalt2); + }); + + it("uses the fallback salt when env var is absent", () => { + delete process.env["AUDIT_PSEUDONYM_SALT"]; + // Should not throw; just use the fallback. + const result = pseudonymize("user_1"); + expect(result).toMatch(/^erased-[0-9a-f]{16}$/); + }); +}); diff --git a/turbo/generators/templates/core-package/audit/src/pseudonymize.ts.hbs b/turbo/generators/templates/core-package/audit/src/pseudonymize.ts.hbs new file mode 100644 index 0000000..4439cb1 --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/pseudonymize.ts.hbs @@ -0,0 +1,22 @@ +import { createHash } from "node:crypto"; + +/** + * Produces a stable, irreversible token for a GDPR-erased actorId. + * + * Format: `erased-` + * + * The salt is read from `AUDIT_PSEUDONYM_SALT` env at call time so that + * production binding can pre-validate the var at boot (see `bindAudit`) + * while tests can override it per-test via `process.env`. + * + * Fallback salt is intentionally weak and labelled so that any token + * produced with it is recognisable as a dev/test artefact. + */ +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)}`; +} diff --git a/turbo/generators/templates/core-package/audit/src/stdout-json-audit-log.test.ts.hbs b/turbo/generators/templates/core-package/audit/src/stdout-json-audit-log.test.ts.hbs new file mode 100644 index 0000000..81e32ad --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/stdout-json-audit-log.test.ts.hbs @@ -0,0 +1,49 @@ +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", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let writeSpy: ReturnType>; + 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 + }); +}); diff --git a/turbo/generators/templates/core-package/audit/src/stdout-json-audit-log.ts.hbs b/turbo/generators/templates/core-package/audit/src/stdout-json-audit-log.ts.hbs new file mode 100644 index 0000000..9d46493 --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/stdout-json-audit-log.ts.hbs @@ -0,0 +1,35 @@ +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 { + const serialized = JSON.stringify({ + _type: "audit", + ...entry, + at: entry.at.toISOString(), + }); + process.stdout.write(serialized + "\n"); + } + + async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise { + const tombstone = { + _type: "audit-erasure", + actorId, + mode, + at: new Date().toISOString(), + }; + process.stdout.write(JSON.stringify(tombstone) + "\n"); + } +} diff --git a/turbo/generators/templates/core-package/audit/src/trace-id-enriching-audit-log.test.ts.hbs b/turbo/generators/templates/core-package/audit/src/trace-id-enriching-audit-log.test.ts.hbs new file mode 100644 index 0000000..59147d7 --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/trace-id-enriching-audit-log.test.ts.hbs @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { context, trace } from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"; +import { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log"; +import type { AuditEntry } from "@repo/core-shared/audit"; +import type { IAuditLog } from "./audit-log.interface"; + +// Register async context manager so startActiveSpan propagates context. +const ctxManager = new AsyncLocalStorageContextManager(); +ctxManager.enable(); +context.setGlobalContextManager(ctxManager); + +function setupProvider(): { exporter: InMemorySpanExporter; provider: BasicTracerProvider } { + const exporter = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(exporter)], + }); + trace.setGlobalTracerProvider(provider); + return { exporter, provider }; +} + +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", () => { + let exporter: InMemorySpanExporter; + let provider: BasicTracerProvider; + + beforeEach(() => { + ({ exporter, provider } = setupProvider()); + }); + + afterEach(async () => { + await provider.shutdown(); + trace.disable(); + void exporter; + }); + + 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((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((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"); + }); +}); diff --git a/turbo/generators/templates/core-package/audit/src/trace-id-enriching-audit-log.ts.hbs b/turbo/generators/templates/core-package/audit/src/trace-id-enriching-audit-log.ts.hbs new file mode 100644 index 0000000..6829ba1 --- /dev/null +++ b/turbo/generators/templates/core-package/audit/src/trace-id-enriching-audit-log.ts.hbs @@ -0,0 +1,30 @@ +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(readonly inner: IAuditLog) {} + + async record(entry: AuditEntry): Promise { + 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 { + return this.inner.eraseSubject(actorId, mode); + } +} diff --git a/turbo/generators/templates/core-package/audit/tsconfig.json.hbs b/turbo/generators/templates/core-package/audit/tsconfig.json.hbs new file mode 100644 index 0000000..8facef5 --- /dev/null +++ b/turbo/generators/templates/core-package/audit/tsconfig.json.hbs @@ -0,0 +1,12 @@ +{ + "extends": "@repo/core-typescript/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/turbo/generators/templates/core-package/audit/turbo.json.hbs b/turbo/generators/templates/core-package/audit/turbo.json.hbs new file mode 100644 index 0000000..dcb8fb3 --- /dev/null +++ b/turbo/generators/templates/core-package/audit/turbo.json.hbs @@ -0,0 +1,4 @@ +{ + "extends": ["//"], + "tags": ["core"] +} diff --git a/turbo/generators/templates/core-package/audit/vitest.config.ts.hbs b/turbo/generators/templates/core-package/audit/vitest.config.ts.hbs new file mode 100644 index 0000000..2ee07c1 --- /dev/null +++ b/turbo/generators/templates/core-package/audit/vitest.config.ts.hbs @@ -0,0 +1,9 @@ +import path from "node:path"; +import { mergeConfig } from "vitest/config"; +import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node"; + +export default mergeConfig(nodeVitestConfig, { + resolve: { + alias: { "@": path.resolve(__dirname, "./src") }, + }, +});