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 { PayloadAuditLog } from "./payload-audit-log";
|
||||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||||
|
|
||||||
@@ -39,3 +39,90 @@ describe("PayloadAuditLog.record", () => {
|
|||||||
expect(call.data.outcome).toBe("success");
|
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 { SanitizedConfig } from "payload";
|
||||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||||
import type { IAuditLog } from "./audit-log.interface";
|
import type { IAuditLog } from "./audit-log.interface";
|
||||||
|
import { pseudonymize } from "./pseudonymize";
|
||||||
|
|
||||||
type GetPayload = (args: { config: SanitizedConfig }) => Promise<{
|
type GetPayload = (args: { config: SanitizedConfig }) => Promise<{
|
||||||
create: (args: { collection: string; data: Record<string, unknown> }) => Promise<unknown>;
|
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> {
|
async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
|
||||||
// Implemented in Phase 3.
|
const payload = await this.getPayload({ config: this.config });
|
||||||
throw new Error("PayloadAuditLog.eraseSubject not yet implemented (Phase 3)");
|
|
||||||
|
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