Initial commit
This commit is contained in:
103
packages/core-audit/src/payload-audit-log.ts
Normal file
103
packages/core-audit/src/payload-audit-log.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user