feat(core-audit): PayloadAuditLog.eraseSubject (pseudonymize + delete via overrideAccess)
Replaces the Phase-2 stub with a real impl. Mode "delete" issues a bulk payload.delete with overrideAccess:true to bypass the append-only rule. Mode "pseudonymize" fetches up to 10_000 matching docs and patches each actorId to the token produced by pseudonymize(). Adds 3 eraseSubject unit tests to the existing payload-audit-log test file. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { PayloadAuditLog } from "./payload-audit-log";
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
|
||||
@@ -39,3 +39,90 @@ describe("PayloadAuditLog.record", () => {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>; 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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<string, unknown> }) => Promise<unknown>;
|
||||
@@ -66,8 +67,37 @@ export class PayloadAuditLog implements IAuditLog {
|
||||
});
|
||||
}
|
||||
|
||||
async eraseSubject(_actorId: string, _mode: "pseudonymize" | "delete"): Promise<void> {
|
||||
// Implemented in Phase 3.
|
||||
throw new Error("PayloadAuditLog.eraseSubject not yet implemented (Phase 3)");
|
||||
async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user