feat(core-audit): PayloadAuditLog.record impl (eraseSubject lands in Phase 3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
41
packages/core-audit/src/payload-audit-log.test.ts
Normal file
41
packages/core-audit/src/payload-audit-log.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { PayloadAuditLog } from "./payload-audit-log";
|
||||||
|
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||||
|
|
||||||
|
const sample: AuditEntry = {
|
||||||
|
actorId: "user_1",
|
||||||
|
actorType: "user",
|
||||||
|
actorRoles: ["admin"],
|
||||||
|
action: "UPDATE",
|
||||||
|
resource: { type: "articles", id: "abc" },
|
||||||
|
changedFields: ["title", "body"],
|
||||||
|
at: new Date("2026-05-11T10:00:00.000Z"),
|
||||||
|
scope: { feature: "blog", environment: "production", tenant: "default" },
|
||||||
|
from: { ipTruncated: "10.0.0.0", userAgent: "Mozilla/5.0" },
|
||||||
|
containsPii: false,
|
||||||
|
outcome: "success",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("PayloadAuditLog.record", () => {
|
||||||
|
it("maps AuditEntry → flat collection doc + calls payload.create", async () => {
|
||||||
|
const mockCreate = vi.fn().mockResolvedValue({ id: "doc_1" });
|
||||||
|
const mockGetPayload = vi.fn().mockResolvedValue({ create: mockCreate });
|
||||||
|
const log = new PayloadAuditLog({} as never, mockGetPayload);
|
||||||
|
|
||||||
|
await log.record(sample);
|
||||||
|
|
||||||
|
expect(mockCreate).toHaveBeenCalledOnce();
|
||||||
|
const call = mockCreate.mock.calls[0]![0] as { collection: string; data: Record<string, unknown> };
|
||||||
|
expect(call.collection).toBe("audit-logs");
|
||||||
|
expect(call.data.actorId).toBe("user_1");
|
||||||
|
expect(call.data.action).toBe("UPDATE");
|
||||||
|
expect(call.data.resourceType).toBe("articles");
|
||||||
|
expect(call.data.resourceId).toBe("abc");
|
||||||
|
expect(call.data.changedFields).toEqual(["title", "body"]);
|
||||||
|
expect(call.data.scopeFeature).toBe("blog");
|
||||||
|
expect(call.data.scopeTenant).toBe("default");
|
||||||
|
expect(call.data.ipTruncated).toBe("10.0.0.0");
|
||||||
|
expect(call.data.containsPii).toBe(false);
|
||||||
|
expect(call.data.outcome).toBe("success");
|
||||||
|
});
|
||||||
|
});
|
||||||
73
packages/core-audit/src/payload-audit-log.ts
Normal file
73
packages/core-audit/src/payload-audit-log.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import type { SanitizedConfig } from "payload";
|
||||||
|
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||||
|
import type { IAuditLog } from "./audit-log.interface";
|
||||||
|
|
||||||
|
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> {
|
||||||
|
// Implemented in Phase 3.
|
||||||
|
throw new Error("PayloadAuditLog.eraseSubject not yet implemented (Phase 3)");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user