From 9f90f0513f98e69741ee5123290c29be373ec62f Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Fri, 10 Jul 2026 18:14:52 +0200 Subject: [PATCH] feat(core-dsr): include the subject's audit trail in DSR exports UserDataBundle advertised an auditLog field that the export never populated (audit finding A14). PayloadDataExport now queries the audit-logs collection scoped to actorId === subjectId and reconstructs AuditEntry values from the flat rows; when the audit core's collection is not registered the field stays undefined. Co-Authored-By: Claude Fable 5 --- .../src/__tests__/payload-data-export.test.ts | 100 ++++++++++++++++++ packages/core-dsr/src/payload-data-export.ts | 68 ++++++++++++ 2 files changed, 168 insertions(+) diff --git a/packages/core-dsr/src/__tests__/payload-data-export.test.ts b/packages/core-dsr/src/__tests__/payload-data-export.test.ts index b11f67e..699fafd 100644 --- a/packages/core-dsr/src/__tests__/payload-data-export.test.ts +++ b/packages/core-dsr/src/__tests__/payload-data-export.test.ts @@ -261,3 +261,103 @@ describe("PayloadDataExport", () => { expect(Object.keys(bundle.data)).toEqual(["users", "orders"]); }); }); + +describe("PayloadDataExport — audit log in the bundle (A14)", () => { + it("populates bundle.auditLog from the audit-logs collection, scoped to the subject", async () => { + const auditLog = new RecordingAuditLog(); + const find = vi.fn(async (args: { collection: string }) => { + if (args.collection === "audit-logs") { + return { + docs: [ + { + id: "log-1", + actorId: "alice", + actorType: "user", + actorRoles: ["author"], + action: "EXPORT", + resourceType: "subject-data", + resourceId: null, + changedFields: null, + scopeFeature: "core-dsr", + scopeEnvironment: "test", + scopeTenant: "default", + reason: null, + correlationId: "corr-1", + requestId: null, + ipTruncated: "system", + userAgent: "system", + containsPii: false, + piiCategories: null, + outcome: "success", + errorCode: null, + createdAt: "2026-01-01T00:00:00.000Z", + }, + ], + }; + } + return { docs: [{ id: "alice", email: "a@ex.com" }] }; + }); + const getPayload = vi.fn(async () => ({ find })); + const config = { + collections: [ + { + slug: "users", + custom: { + subject: { field: "id", kind: "self" }, + pii: { email: { exportable: true } }, + }, + }, + { slug: "audit-logs" }, + ], + } as unknown as SanitizedConfig; + + const exporter = new PayloadDataExport(config, auditLog, getPayload); + const bundle = await exporter.exportSubjectData("alice", "json"); + + expect(find).toHaveBeenCalledWith( + expect.objectContaining({ + collection: "audit-logs", + where: { actorId: { equals: "alice" } }, + }), + ); + expect(bundle.auditLog).toHaveLength(1); + const entry = bundle.auditLog![0]!; + expect(entry.actorId).toBe("alice"); + expect(entry.action).toBe("EXPORT"); + expect(entry.correlationId).toBe("corr-1"); + expect(entry.at).toEqual(new Date("2026-01-01T00:00:00.000Z")); + expect(entry.scope).toEqual({ + feature: "core-dsr", + environment: "test", + tenant: "default", + }); + }); + + it("leaves bundle.auditLog undefined when the audit-logs collection is absent", async () => { + const config = makeMockConfig([ + { + slug: "users", + custom: { + subject: { field: "id", kind: "self" }, + pii: { email: { exportable: true } }, + }, + }, + ]); + const auditLog = new RecordingAuditLog(); + const mock = makeMockPayload(); + mock.find.mockResolvedValue({ docs: [{ id: "alice", email: "x" }] }); + const exporter = new PayloadDataExport( + config, + auditLog, + vi.fn().mockResolvedValue(mock), + ); + const bundle = await exporter.exportSubjectData("alice", "json"); + expect(bundle.auditLog).toBeUndefined(); + // no stray find against a non-registered collection + expect( + mock.find.mock.calls.some( + (c) => (c[0] as { collection: string }).collection === "audit-logs", + ), + ).toBe(false); + }); +}); diff --git a/packages/core-dsr/src/payload-data-export.ts b/packages/core-dsr/src/payload-data-export.ts index 647ea63..158f8ed 100644 --- a/packages/core-dsr/src/payload-data-export.ts +++ b/packages/core-dsr/src/payload-data-export.ts @@ -1,6 +1,7 @@ import { getPayload as _getPayload } from "payload"; import type { SanitizedConfig } from "payload"; import type { AuditLogProtocol } from "@repo/core-shared/di"; +import type { AuditEntry } from "@repo/core-shared/audit"; import type { IDataExport } from "./data-export.interface"; import type { DsrFormat, @@ -42,6 +43,54 @@ type PayloadAPI = { type GetPayload = (args: { config: SanitizedConfig }) => Promise; +const AUDIT_LOGS_SLUG = "audit-logs"; + +/** Reconstruct an AuditEntry from its flat audit-logs collection row. */ +function docToAuditEntry(doc: PayloadDoc): AuditEntry { + const str = (v: unknown): string => (v == null ? "" : String(v)); + const opt = (v: unknown): string | undefined => + v == null ? undefined : String(v); + const entry: AuditEntry = { + actorId: str(doc["actorId"]), + actorType: (doc["actorType"] as AuditEntry["actorType"]) ?? "user", + actorRoles: Array.isArray(doc["actorRoles"]) + ? (doc["actorRoles"] as string[]) + : [], + action: doc["action"] as AuditEntry["action"], + resource: { + type: str(doc["resourceType"]), + ...(doc["resourceId"] != null ? { id: String(doc["resourceId"]) } : {}), + }, + at: new Date(str(doc["createdAt"])), + scope: { + feature: str(doc["scopeFeature"]), + environment: str(doc["scopeEnvironment"]), + tenant: str(doc["scopeTenant"]), + }, + from: { + ipTruncated: str(doc["ipTruncated"]), + userAgent: str(doc["userAgent"]), + }, + containsPii: Boolean(doc["containsPii"]), + outcome: (doc["outcome"] as AuditEntry["outcome"]) ?? "success", + }; + if (Array.isArray(doc["changedFields"])) { + entry.changedFields = doc["changedFields"] as string[]; + } + if (Array.isArray(doc["piiCategories"])) { + entry.piiCategories = doc["piiCategories"] as string[]; + } + const reason = opt(doc["reason"]); + if (reason) entry.reason = reason; + const correlationId = opt(doc["correlationId"]); + if (correlationId) entry.correlationId = correlationId; + const requestId = opt(doc["requestId"]); + if (requestId) entry.requestId = requestId; + const errorCode = opt(doc["errorCode"]); + if (errorCode) entry.errorCode = errorCode; + return entry; +} + /** * Payload-backed IDataExport. Walks all collections annotated with * `custom.subject` linkage, segments rows by role (self/owner vs reference), @@ -112,6 +161,21 @@ export class PayloadDataExport implements IDataExport { } } + // GDPR Art. 15(1) includes the processing record: populate the subject's + // audit-log entries when the local audit sink is registered (audit + // finding A14). Scoped strictly to actorId === subjectId; absent + // collection (audit core not scaffolded) → field stays undefined. + let auditEntries: AuditEntry[] | undefined; + if (this.config.collections.some((c) => c.slug === AUDIT_LOGS_SLUG)) { + const result = await payload.find({ + collection: AUDIT_LOGS_SLUG, + where: { actorId: { equals: subjectId } }, + overrideAccess: true, + limit: 1000, + }); + auditEntries = result.docs.map(docToAuditEntry); + } + await this.auditLog.record({ actorId: subjectId, actorType: "user", @@ -136,6 +200,10 @@ export class PayloadDataExport implements IDataExport { data, }; + if (auditEntries) { + bundle.auditLog = auditEntries; + } + if (format === "json-ld") { bundle["@context"] = USER_DATA_JSONLD_CONTEXT; }