From c06f47b81ecdb2662f4d325bc5bab057cb14ea8f Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 11 May 2026 16:33:59 +0200 Subject: [PATCH] feat(core-audit): createAuditAfterReadHook factory for opt-in VIEW capture Adds the afterRead hook factory for per-collection opt-in automatic VIEW audit entry emission. Fire-and-forget design ensures failing sinks never propagate to the user-facing read. Includes sentinel IP/UA fallbacks, truncateIp /24 integration, shouldSkip predicate, and system actor resolution. All 6 TDD test cases pass; both barrels updated. Co-Authored-By: Claude Sonnet 4.6 --- .../src/hooks/audit-after-read-hook.test.ts | 103 +++++++++++++++++ .../src/hooks/audit-after-read-hook.ts | 105 ++++++++++++++++++ packages/core-audit/src/hooks/index.ts | 4 + packages/core-audit/src/index.ts | 5 + 4 files changed, 217 insertions(+) create mode 100644 packages/core-audit/src/hooks/audit-after-read-hook.test.ts create mode 100644 packages/core-audit/src/hooks/audit-after-read-hook.ts diff --git a/packages/core-audit/src/hooks/audit-after-read-hook.test.ts b/packages/core-audit/src/hooks/audit-after-read-hook.test.ts new file mode 100644 index 0000000..fde4fe1 --- /dev/null +++ b/packages/core-audit/src/hooks/audit-after-read-hook.test.ts @@ -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/packages/core-audit/src/hooks/audit-after-read-hook.ts b/packages/core-audit/src/hooks/audit-after-read-hook.ts new file mode 100644 index 0000000..bfa759f --- /dev/null +++ b/packages/core-audit/src/hooks/audit-after-read-hook.ts @@ -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/packages/core-audit/src/hooks/index.ts b/packages/core-audit/src/hooks/index.ts index 40883f5..11dcd14 100644 --- a/packages/core-audit/src/hooks/index.ts +++ b/packages/core-audit/src/hooks/index.ts @@ -2,3 +2,7 @@ export { createAuditErasureHook, type AuditErasureHookOpts, } from "./audit-erasure-hook"; +export { + createAuditAfterReadHook, + type AuditAfterReadHookOpts, +} from "./audit-after-read-hook"; diff --git a/packages/core-audit/src/index.ts b/packages/core-audit/src/index.ts index 01450e1..a76b85e 100644 --- a/packages/core-audit/src/index.ts +++ b/packages/core-audit/src/index.ts @@ -14,6 +14,11 @@ export { createAuditErasureHook, type AuditErasureHookOpts, } from "./hooks/audit-erasure-hook"; +// Phase 5 — VIEW capture +export { + createAuditAfterReadHook, + type AuditAfterReadHookOpts, +} from "./hooks"; export { createAuditRouter, auditRouter,