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 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 18:14:52 +02:00
parent d95ae74aed
commit 9f90f0513f
2 changed files with 168 additions and 0 deletions

View File

@@ -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);
});
});

View File

@@ -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<PayloadAPI>;
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;
}