diff --git a/packages/core-audit/src/payload-audit-log.test.ts b/packages/core-audit/src/payload-audit-log.test.ts new file mode 100644 index 0000000..daffe95 --- /dev/null +++ b/packages/core-audit/src/payload-audit-log.test.ts @@ -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 }; + 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"); + }); +}); diff --git a/packages/core-audit/src/payload-audit-log.ts b/packages/core-audit/src/payload-audit-log.ts new file mode 100644 index 0000000..e819909 --- /dev/null +++ b/packages/core-audit/src/payload-audit-log.ts @@ -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 }) => Promise; + find: (args: { + collection: string; + where: Record; + limit: number; + overrideAccess: true; + }) => Promise<{ docs: Array<{ id: string | number }> }>; + update: (args: { + collection: string; + id: string | number; + data: Record; + overrideAccess: true; + }) => Promise; + delete: (args: { + collection: string; + where: Record; + overrideAccess: true; + }) => Promise; +}>; + +/** + * 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 { + 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 { + // Implemented in Phase 3. + throw new Error("PayloadAuditLog.eraseSubject not yet implemented (Phase 3)"); + } +}