Files
agentic-dev/packages/core-audit/src/payload-audit-log.ts
Danijel Martinek 18fddcc45f 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>
2026-05-11 16:22:35 +02:00

104 lines
3.4 KiB
TypeScript

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>;
find: (args: {
collection: string;
where: Record<string, unknown>;
limit: number;
overrideAccess: true;
}) => Promise<{ docs: Array<{ id: string | number }> }>;
update: (args: {
collection: string;
id: string | number;
data: Record<string, unknown>;
overrideAccess: true;
}) => Promise<unknown>;
delete: (args: {
collection: string;
where: Record<string, unknown>;
overrideAccess: true;
}) => Promise<unknown>;
}>;
/**
* Local-cache audit sink: writes entries to the `audit-logs` Payload
* collection. The collection is append-only by access-rule
* (`update: () => false`); the eraseSubject path uses `overrideAccess: true`
* to bypass for the privileged GDPR pseudonymization op.
*
* The getPayload param is injectable for tests; production callers pass
* the real `getPayload` from `payload`.
*/
export class PayloadAuditLog implements IAuditLog {
constructor(
private readonly config: SanitizedConfig,
private readonly getPayload: GetPayload,
) {}
async record(entry: AuditEntry): Promise<void> {
const payload = await this.getPayload({ config: this.config });
await payload.create({
collection: "audit-logs",
data: {
actorId: entry.actorId,
actorType: entry.actorType,
actorRoles: entry.actorRoles,
action: entry.action,
resourceType: entry.resource.type,
resourceId: entry.resource.id ?? null,
changedFields: entry.changedFields ?? null,
scopeFeature: entry.scope.feature,
scopeEnvironment: entry.scope.environment,
scopeTenant: entry.scope.tenant,
reason: entry.reason ?? null,
correlationId: entry.correlationId ?? null,
requestId: entry.requestId ?? null,
ipTruncated: entry.from.ipTruncated,
userAgent: entry.from.userAgent,
containsPii: entry.containsPii,
piiCategories: entry.piiCategories ?? null,
outcome: entry.outcome,
errorCode: entry.errorCode ?? null,
},
});
}
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,
});
}
}
}