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; }