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"]);
|
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 { getPayload as _getPayload } from "payload";
|
||||||
import type { SanitizedConfig } from "payload";
|
import type { SanitizedConfig } from "payload";
|
||||||
import type { AuditLogProtocol } from "@repo/core-shared/di";
|
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 { IDataExport } from "./data-export.interface";
|
||||||
import type {
|
import type {
|
||||||
DsrFormat,
|
DsrFormat,
|
||||||
@@ -42,6 +43,54 @@ type PayloadAPI = {
|
|||||||
|
|
||||||
type GetPayload = (args: { config: SanitizedConfig }) => Promise<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
|
* Payload-backed IDataExport. Walks all collections annotated with
|
||||||
* `custom.subject` linkage, segments rows by role (self/owner vs reference),
|
* `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({
|
await this.auditLog.record({
|
||||||
actorId: subjectId,
|
actorId: subjectId,
|
||||||
actorType: "user",
|
actorType: "user",
|
||||||
@@ -136,6 +200,10 @@ export class PayloadDataExport implements IDataExport {
|
|||||||
data,
|
data,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (auditEntries) {
|
||||||
|
bundle.auditLog = auditEntries;
|
||||||
|
}
|
||||||
|
|
||||||
if (format === "json-ld") {
|
if (format === "json-ld") {
|
||||||
bundle["@context"] = USER_DATA_JSONLD_CONTEXT;
|
bundle["@context"] = USER_DATA_JSONLD_CONTEXT;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user