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:
103
packages/core-audit/src/hooks/audit-after-read-hook.test.ts
Normal file
103
packages/core-audit/src/hooks/audit-after-read-hook.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
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;
|
||||||
|
}
|
||||||
@@ -2,3 +2,7 @@ export {
|
|||||||
createAuditErasureHook,
|
createAuditErasureHook,
|
||||||
type AuditErasureHookOpts,
|
type AuditErasureHookOpts,
|
||||||
} from "./audit-erasure-hook";
|
} from "./audit-erasure-hook";
|
||||||
|
export {
|
||||||
|
createAuditAfterReadHook,
|
||||||
|
type AuditAfterReadHookOpts,
|
||||||
|
} from "./audit-after-read-hook";
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ export {
|
|||||||
createAuditErasureHook,
|
createAuditErasureHook,
|
||||||
type AuditErasureHookOpts,
|
type AuditErasureHookOpts,
|
||||||
} from "./hooks/audit-erasure-hook";
|
} from "./hooks/audit-erasure-hook";
|
||||||
|
// Phase 5 — VIEW capture
|
||||||
|
export {
|
||||||
|
createAuditAfterReadHook,
|
||||||
|
type AuditAfterReadHookOpts,
|
||||||
|
} from "./hooks";
|
||||||
export {
|
export {
|
||||||
createAuditRouter,
|
createAuditRouter,
|
||||||
auditRouter,
|
auditRouter,
|
||||||
|
|||||||
Reference in New Issue
Block a user