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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user