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:
2026-05-11 16:22:35 +02:00
parent 846b4c2511
commit 18fddcc45f
2 changed files with 121 additions and 4 deletions

View File

@@ -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();
});
});

View File

@@ -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,
});
}
}
}