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 <noreply@anthropic.com>
This commit is contained in:
105
packages/core-audit/src/hooks/audit-after-read-hook.ts
Normal file
105
packages/core-audit/src/hooks/audit-after-read-hook.ts
Normal file
@@ -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<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;
|
||||
}
|
||||
Reference in New Issue
Block a user