fix(compliance): port DSR/consent/audit/retention audit fixes

Ports the upstream compliance-core audit fixes onto the kept core-dsr,
core-consent, core-audit, core-cms and core-shared packages:

- core-dsr: scope DSR operations to the caller's own subject (A11);
  include the subject's audit trail in exports; resolve the per-request
  binding from ctx instead of a throwing singleton proxy.
- core-consent: build the consent router from the shared superjson
  transformer (A10); merge per-category on persist instead of replacing;
  validate migrated categories against an allow-list.
- core-audit: keyed 128-bit pseudonyms + salted DSR certificate; add the
  audit-logs collection and the req-scoped GDPR audit-erasure afterDelete
  hook (A6).
- core-shared: grace-purge soft-deleted rows via a retention-purge task +
  tombstone field and boot registration (A2/A3); add the
  require-authenticated tRPC helper; derive clientIp + resolve the session
  user in createTrpcContext (B2/A11).
- core-cms: register audit-logs, wire the audit-erasure hook and
  retention-purge tasks; adapted to the clean-slate collection set
  (users only — no workspaces feature on this branch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
2026-07-13 06:07:25 +02:00
parent 4e4cd5fa7c
commit f2f24f7bfa
42 changed files with 2065 additions and 129 deletions

View File

@@ -208,15 +208,127 @@ describe("dsrRouter.restrict", () => {
});
});
describe("dsrRouter singleton guard", () => {
it("throws when procedures are called without a real DsrBinding", async () => {
// The singleton uses a Proxy that throws on any binding property access.
// Procedures access binding lazily, so the Proxy error surfaces at call time.
describe("dsrRouter subject scoping (A1 — IDOR)", () => {
it("rejects a non-admin export for another subject with FORBIDDEN", async () => {
const binding = makeBinding();
const caller = makeCaller(binding, authenticatedUser);
await expect(
caller.export({ subjectId: "bob", format: "json" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(binding.dataExport.calls).toHaveLength(0);
});
it("rejects a non-admin delete for another subject with FORBIDDEN", async () => {
const binding = makeBinding();
const caller = makeCaller(binding, authenticatedUser);
await expect(
caller.delete({ subjectId: "bob", mode: "soft" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(binding.dataDelete.calls).toHaveLength(0);
});
it("rejects a non-admin rectify for another subject with FORBIDDEN", async () => {
const binding = makeBinding();
const caller = makeCaller(binding, authenticatedUser);
await expect(
caller.rectify({
subjectId: "bob",
collection: "users",
field: "name",
value: "x",
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(binding.dataRectify.calls).toHaveLength(0);
});
it("rejects a non-admin restrict for another subject with FORBIDDEN", async () => {
const binding = makeBinding();
const caller = makeCaller(binding, authenticatedUser);
await expect(
caller.restrict({ subjectId: "bob", granted: true }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(binding.processingRestriction.sets).toHaveLength(0);
});
it("rejects a non-admin user without an id acting on any subject", async () => {
const binding = makeBinding();
const caller = makeCaller(binding, { roles: ["user"] });
await expect(
caller.export({ subjectId: "alice", format: "json" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("allows a non-admin to act on themselves for every operation", async () => {
const binding = makeBinding();
const caller = makeCaller(binding, authenticatedUser);
await caller.export({ subjectId: "alice", format: "json" });
await caller.delete({ subjectId: "alice", mode: "soft" });
await caller.rectify({
subjectId: "alice",
collection: "users",
field: "name",
value: "Alice",
});
await caller.restrict({ subjectId: "alice", granted: true });
expect(binding.dataExport.calls).toHaveLength(1);
expect(binding.dataDelete.calls).toHaveLength(1);
expect(binding.dataRectify.calls).toHaveLength(1);
expect(binding.processingRestriction.sets).toHaveLength(1);
});
it("allows an admin to act cross-subject on every operation", async () => {
const binding = makeBinding();
const caller = makeCaller(binding, adminUser);
await caller.export({ subjectId: "alice", format: "json" });
await caller.delete({ subjectId: "alice", mode: "soft" });
await caller.rectify({
subjectId: "alice",
collection: "users",
field: "name",
value: "Alice",
});
await caller.restrict({ subjectId: "alice", granted: true });
expect(binding.dataExport.calls).toHaveLength(1);
expect(binding.dataDelete.calls).toHaveLength(1);
expect(binding.dataRectify.calls).toHaveLength(1);
expect(binding.processingRestriction.sets).toHaveLength(1);
});
});
describe("dsrRouter singleton (context-time binding, A11)", () => {
it("fails loudly when neither ctx.dsrBinding nor a creation binding exists", async () => {
const caller = dsrRouter.createCaller({
user: authenticatedUser,
} as Record<string, unknown>);
await expect(
caller.export({ subjectId: "alice", format: "json" }),
).rejects.toThrow(/dsrRouter singleton/);
).rejects.toMatchObject({
code: "INTERNAL_SERVER_ERROR",
message: expect.stringContaining("DsrBinding missing"),
});
});
it("serves requests when the app provides ctx.dsrBinding", async () => {
const binding = makeBinding();
const caller = dsrRouter.createCaller({
user: authenticatedUser,
dsrBinding: binding,
} as Record<string, unknown>);
const result = await caller.export({ subjectId: "alice", format: "json" });
expect(result.subjectId).toBe("alice");
expect(binding.dataExport.calls).toHaveLength(1);
});
it("prefers ctx.dsrBinding over the creation-time binding", async () => {
const creationBinding = makeBinding();
const ctxBinding = makeBinding();
const router = createDsrRouter(creationBinding as unknown as DsrBinding);
const caller = router.createCaller({
user: authenticatedUser,
dsrBinding: ctxBinding,
} as Record<string, unknown>);
await caller.export({ subjectId: "alice", format: "json" });
expect(ctxBinding.dataExport.calls).toHaveLength(1);
expect(creationBinding.dataExport.calls).toHaveLength(0);
});
});

View File

@@ -97,6 +97,152 @@ describe("PayloadDataDelete", () => {
);
});
it("stamps the deletedAt tombstone when the collection declares postDeletion retention (A2)", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
retention: {
purgeSchedule: "daily",
postDeletion: {
duration: "P30D",
trigger: "after-deletion",
action: "hard-delete",
},
},
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "alice", email: "a@ex.com" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
await deleter.deleteSubjectData("alice", "soft");
expect(mockPayload.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
deletedAt: expect.any(String),
processingRestrictedAt: expect.any(String),
}),
}),
);
});
it("does NOT stamp deletedAt without a postDeletion retention policy", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "alice", email: "a@ex.com" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
await deleter.deleteSubjectData("alice", "soft");
const updateData = mockPayload.update.mock.calls[0]?.[0]?.data as Record<
string,
unknown
>;
expect(updateData).not.toHaveProperty("deletedAt");
});
it("stamps deletedAt on owner rows of postDeletion collections", async () => {
const config = makeMockConfig([
{
slug: "orders",
custom: {
subject: { field: "userId", kind: "owner" },
pii: { shippingAddress: { exportable: true } },
retention: {
purgeSchedule: "daily",
postDeletion: {
duration: "P90D",
trigger: "after-deletion",
action: "hard-delete",
},
},
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "o1", userId: "alice", shippingAddress: "X" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
await deleter.deleteSubjectData("alice", "soft");
const updateData = mockPayload.update.mock.calls[0]?.[0]?.data as Record<
string,
unknown
>;
expect(updateData["deletedAt"]).toEqual(expect.any(String));
expect(updateData).not.toHaveProperty("processingRestrictedAt");
});
it("redacts the auth-injected email field for a users-shaped collection (A5)", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: {
email: {
category: "contact-email",
purpose: ["account-authentication"],
exportable: true,
restrictable: true,
},
username: {
category: "identification-username",
purpose: ["service-delivery"],
exportable: true,
restrictable: true,
},
displayName: {
category: "identification-username",
purpose: ["service-delivery"],
exportable: true,
restrictable: true,
},
},
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "alice", email: "alice@example.com", username: "alice" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
const cert = await deleter.deleteSubjectData("alice", "soft");
expect(mockPayload.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
email: null,
username: null,
displayName: null,
}),
}),
);
expect(cert.affected[0]?.fields).toEqual(
expect.arrayContaining(["email", "username", "displayName"]),
);
});
it("owner role: does NOT set processingRestrictedAt", async () => {
const config = makeMockConfig([
{
@@ -367,3 +513,67 @@ describe("PayloadDataDelete", () => {
});
});
});
describe("cascade-hard audit erasure (A6)", () => {
it("calls auditErasure.eraseSubject with pseudonymize after cascade-hard", async () => {
const auditLog = new RecordingAuditLog();
const mockPayload = {
find: vi.fn().mockResolvedValue({ docs: [{ id: "alice" }] }),
update: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
};
const eraseSubject = vi.fn().mockResolvedValue(undefined);
const config = {
collections: [
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
},
},
],
} as unknown as SanitizedConfig;
const deleter = new PayloadDataDelete(
config,
auditLog,
vi.fn().mockResolvedValue(mockPayload),
{ eraseSubject },
);
await deleter.deleteSubjectData("alice", "cascade-hard");
expect(eraseSubject).toHaveBeenCalledWith("alice", "pseudonymize");
});
it("does not erase audit entries on soft delete", async () => {
const auditLog = new RecordingAuditLog();
const mockPayload = {
find: vi.fn().mockResolvedValue({ docs: [{ id: "alice" }] }),
update: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
};
const eraseSubject = vi.fn().mockResolvedValue(undefined);
const config = {
collections: [
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
},
},
],
} as unknown as SanitizedConfig;
const deleter = new PayloadDataDelete(
config,
auditLog,
vi.fn().mockResolvedValue(mockPayload),
{ eraseSubject },
);
await deleter.deleteSubjectData("alice", "soft");
expect(eraseSubject).not.toHaveBeenCalled();
});
});

View File

@@ -72,6 +72,60 @@ describe("PayloadDataExport", () => {
expect(bundle.data["users"]?.asReference).toBeUndefined();
});
it("exports the auth-injected email field for a users-shaped collection (A5)", async () => {
// Mirrors packages/auth users collection: email is auto-added by Payload
// `auth: true` and declared only in the collection-level custom.pii map.
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: {
email: {
category: "contact-email",
purpose: ["account-authentication"],
exportable: true,
restrictable: true,
},
username: {
category: "identification-username",
purpose: ["service-delivery"],
exportable: true,
restrictable: true,
},
displayName: {
category: "identification-username",
purpose: ["service-delivery"],
exportable: true,
restrictable: true,
},
},
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [
{
id: "alice",
email: "alice@example.com",
username: "alice",
displayName: "Alice",
passwordHash: "secret-hash",
},
],
});
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
const bundle = await exporter.exportSubjectData("alice", "json");
const row = bundle.data["users"]!.asSelf![0]!;
expect(row["email"]).toBe("alice@example.com");
expect(row["username"]).toBe("alice");
expect(row["displayName"]).toBe("Alice");
expect(row).not.toHaveProperty("passwordHash");
});
it("happy path — owner role: includes exportable PII fields", async () => {
const config = makeMockConfig([
{
@@ -207,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

@@ -5,7 +5,7 @@ import type { IDataDelete } from "../data-delete.interface";
import type { IDataRectify } from "../data-rectify.interface";
import type { IProcessingRestriction } from "../processing-restriction.interface";
import { PayloadDataExport } from "../payload-data-export";
import { PayloadDataDelete } from "../payload-data-delete";
import { PayloadDataDelete, type AuditErasure } from "../payload-data-delete";
import { PayloadDataRectify } from "../payload-data-rectify";
import { PayloadProcessingRestriction } from "../payload-processing-restriction";
@@ -19,6 +19,12 @@ export type DsrBinding = {
export type BindProductionDsrOpts = {
config: SanitizedConfig;
auditLog?: AuditLogProtocol;
/**
* Privileged audit-erasure surface (core-audit's IAuditLog satisfies it).
* When present, cascade-hard deletions pseudonymize the subject's
* audit-log entries (A6).
*/
auditErasure?: AuditErasure;
};
const noopAuditLog: AuditLogProtocol = { record: async () => {} };
@@ -34,7 +40,12 @@ export function bindProductionDsr(opts: BindProductionDsrOpts): DsrBinding {
const auditLog = opts.auditLog ?? noopAuditLog;
return {
dataExport: new PayloadDataExport(opts.config, auditLog),
dataDelete: new PayloadDataDelete(opts.config, auditLog),
dataDelete: new PayloadDataDelete(
opts.config,
auditLog,
undefined,
opts.auditErasure,
),
dataRectify: new PayloadDataRectify(opts.config, auditLog),
processingRestriction: new PayloadProcessingRestriction(
opts.config,

View File

@@ -29,21 +29,65 @@ const dsrProcedure = t.procedure
.use(requireAuthenticated)
.use(defineErrorMiddleware([]));
/**
* Subject-scope guard (audit finding A1 — DSR IDOR).
*
* Non-admin callers may act ONLY on themselves: a request whose
* `input.subjectId` differs from the authenticated user's id is rejected
* with FORBIDDEN (rejected, not silently rewritten, so the mismatch is
* visible to the caller). Any cross-subject operation requires the
* "admin" role.
*/
function assertSubjectScope(user: DsrTrpcUser, subjectId: string): void {
if (user.roles?.includes("admin")) return;
if (user.id !== undefined && user.id === subjectId) return;
throw new TRPCError({
code: "FORBIDDEN",
message: "DSR operations on another subject require the admin role",
});
}
function userFromCtx(ctx: object): DsrTrpcUser {
return (ctx as { user: DsrTrpcUser }).user;
}
/** tRPC context consumed by the DSR router (provided by the app's createContext). */
export type DsrRouterContext = {
user?: DsrTrpcUser;
/** Per-request DSR binding — the app wires bindProductionDsr/bindDevSeedDsr output here. */
dsrBinding?: DsrBinding;
};
function bindingFromCtx(ctx: object, fallback?: DsrBinding): DsrBinding {
const fromCtx = (ctx as DsrRouterContext).dsrBinding;
if (fromCtx) return fromCtx;
if (fallback) return fallback;
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
"DsrBinding missing — provide ctx.dsrBinding from createContext " +
"(bindProductionDsr/bindDevSeedDsr) or pass a binding to createDsrRouter",
});
}
/**
* Creates the DSR tRPC router.
*
* Capture `binding` at router-creation time. Apps that mount this router
* must pass the `DsrBinding` returned by `bindProductionDsr` or `bindDevSeedDsr`.
* The binding is resolved per request from `ctx.dsrBinding` (audit finding
* A11 — the mounted router must be live, not a dead stub), falling back to
* the optional `binding` captured at router-creation time.
*
* @example
* ```ts
* // creation-time binding
* const binding = bindProductionDsr({ config, auditLog });
* const appRouter = t.router({ ..., dsr: createDsrRouter(binding) });
*
* // or context-time binding (what apps mounting the `dsrRouter` singleton do)
* createContext: () => ({ user, dsrBinding })
* ```
*/
export function createDsrRouter(binding: DsrBinding) {
// Handlers are created lazily (inside procedure closures) so that the
// dsrRouter singleton proxy doesn't trigger at module init time.
export function createDsrRouter(binding?: DsrBinding) {
return t.router({
export: dsrProcedure
.input(
@@ -54,8 +98,10 @@ export function createDsrRouter(binding: DsrBinding) {
})
.strict(),
)
.query(async ({ input }) => {
const res = await createExportHandler(binding.dataExport)(input);
.query(async ({ ctx, input }) => {
assertSubjectScope(userFromCtx(ctx), input.subjectId);
const b = bindingFromCtx(ctx, binding);
const res = await createExportHandler(b.dataExport)(input);
return res.body;
}),
@@ -69,16 +115,16 @@ export function createDsrRouter(binding: DsrBinding) {
.strict(),
)
.mutation(async ({ ctx, input }) => {
if (input.mode === "cascade-hard") {
const user = (ctx as { user: DsrTrpcUser }).user;
if (!user.roles?.includes("admin")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin role required for cascade-hard deletion",
});
}
const user = userFromCtx(ctx);
assertSubjectScope(user, input.subjectId);
if (input.mode === "cascade-hard" && !user.roles?.includes("admin")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin role required for cascade-hard deletion",
});
}
const res = await createDeleteHandler(binding.dataDelete)(input);
const b = bindingFromCtx(ctx, binding);
const res = await createDeleteHandler(b.dataDelete)(input);
return res.body;
}),
@@ -93,9 +139,11 @@ export function createDsrRouter(binding: DsrBinding) {
})
.strict(),
)
.mutation(async ({ input }) => {
.mutation(async ({ ctx, input }) => {
assertSubjectScope(userFromCtx(ctx), input.subjectId);
const b = bindingFromCtx(ctx, binding);
// tRPC infers z.unknown() as value?: unknown; cast to assert presence
const res = await createRectifyHandler(binding.dataRectify)(
const res = await createRectifyHandler(b.dataRectify)(
input as RectifyHandlerInput,
);
return res.body;
@@ -110,30 +158,21 @@ export function createDsrRouter(binding: DsrBinding) {
})
.strict(),
)
.mutation(async ({ input }) => {
const res = await createRestrictHandler(binding.processingRestriction)(
input,
);
.mutation(async ({ ctx, input }) => {
assertSubjectScope(userFromCtx(ctx), input.subjectId);
const b = bindingFromCtx(ctx, binding);
const res = await createRestrictHandler(b.processingRestriction)(input);
return res.body;
}),
});
}
/**
* Convenience singleton for projects with a single DSR binding instance.
* Most callers should use `createDsrRouter(binding)` and pass the binding
* explicitly. This export exists for type inference (`DsrRouter`) only.
* Router singleton mounted by the app router. It has no creation-time
* binding: every procedure resolves `ctx.dsrBinding`, which the app's
* `createContext` supplies per request (A11). Calls without a context
* binding fail with INTERNAL_SERVER_ERROR at request time.
*/
export const dsrRouter = createDsrRouter(
new Proxy({} as DsrBinding, {
get(_target, prop) {
if (prop === "then") return undefined; // not a Promise
throw new Error(
`dsrRouter singleton used without providing a DsrBinding. ` +
`Use createDsrRouter(binding) instead.`,
);
},
}),
);
export const dsrRouter = createDsrRouter();
export type DsrRouter = ReturnType<typeof createDsrRouter>;

View File

@@ -24,6 +24,7 @@ export type {
export { PayloadDataExport } from "./payload-data-export";
export { PayloadDataDelete } from "./payload-data-delete";
export type { AuditErasure } from "./payload-data-delete";
export { PayloadDataRectify } from "./payload-data-rectify";
export { PayloadProcessingRestriction } from "./payload-processing-restriction";
@@ -37,7 +38,7 @@ export type { DsrBinding, BindProductionDsrOpts } from "./di/bind-production";
export { bindDevSeedDsr } from "./di/bind-dev-seed";
export { createDsrRouter, dsrRouter } from "./dsr.router";
export type { DsrRouter, DsrTrpcUser } from "./dsr.router";
export type { DsrRouter, DsrTrpcUser, DsrRouterContext } from "./dsr.router";
export type { HandlerResponse } from "./handlers/handler-types";
export { createExportHandler } from "./handlers/export-handler";

View File

@@ -1,8 +1,11 @@
import { getPayload as _getPayload } from "payload";
import type { SanitizedConfig } from "payload";
import { randomUUID } from "node:crypto";
import { createHash } from "node:crypto";
import { randomUUID, createHmac } from "node:crypto";
import type { AuditLogProtocol } from "@repo/core-shared/di";
import {
RETENTION_TOMBSTONE_FIELD,
hasPostDeletionPolicy,
} from "@repo/core-shared/payload";
import type { IDataDelete } from "./data-delete.interface";
import type {
DeletionMode,
@@ -35,6 +38,15 @@ type PayloadAPI = {
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
/**
* Privileged audit-erasure surface (structural subset of core-audit's
* IAuditLog — core-dsr must not depend on the optional audit package).
* Wired by the app binder; used on the cascade-hard path (A6).
*/
export type AuditErasure = {
eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void>;
};
function buildWhere(field: string, subjectId: string): Record<string, unknown> {
return field === "id"
? { id: { equals: subjectId } }
@@ -108,6 +120,7 @@ export class PayloadDataDelete implements IDataDelete {
private readonly config: SanitizedConfig,
private readonly auditLog: AuditLogProtocol,
private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload,
private readonly auditErasure?: AuditErasure,
) {}
async deleteSubjectData(
@@ -142,6 +155,7 @@ export class PayloadDataDelete implements IDataDelete {
subjectId,
correlationId,
affected,
hasPostDeletionPolicy(collection),
);
} else {
await this.processReferenceRows(
@@ -157,6 +171,14 @@ export class PayloadDataDelete implements IDataDelete {
}
}
if (mode === "cascade-hard" && this.auditErasure) {
// Erase the subject's audit-log linkage (A6): pseudonymize rather than
// delete so the audit trail keeps its shape for compliance sampling.
// The users afterDelete hook covers Payload-initiated deletes; this
// covers the DSR cascade explicitly and is idempotent with the hook.
await this.auditErasure.eraseSubject(subjectId, "pseudonymize");
}
return this.buildCertificate(
subjectId,
mode,
@@ -175,6 +197,7 @@ export class PayloadDataDelete implements IDataDelete {
subjectId: string,
correlationId: string,
affected: DeletionAffected[],
postDeletionPolicy: boolean,
): Promise<void> {
const piiMeta = custom.pii ?? {};
const exportableFields = Object.entries(piiMeta)
@@ -183,10 +206,17 @@ export class PayloadDataDelete implements IDataDelete {
if (mode === "soft") {
const kind = custom.subject?.kind;
const nowIso = new Date().toISOString();
const extraData: Record<string, unknown> =
kind === "self"
? { processingRestrictedAt: new Date().toISOString() }
: {};
kind === "self" ? { processingRestrictedAt: nowIso } : {};
if (postDeletionPolicy) {
// Soft-delete tombstone (A2): collections with a
// custom.retention.postDeletion policy get stamped so the retention
// purge job can hard-delete/pseudonymize them once the grace period
// elapses. Kept separate from processingRestrictedAt — an Art. 18
// restriction alone must never trigger the purge.
extraData[RETENTION_TOMBSTONE_FIELD] = nowIso;
}
await softRedactOwnerRows(
payload,
slug,
@@ -290,9 +320,20 @@ export class PayloadDataDelete implements IDataDelete {
correlationId: string,
affected: DeletionAffected[],
): DeletionCertificate {
// Salted, keyed pseudonym (audit finding A13): the certificate used to
// hash the raw subjectId with NO salt (truncated to 64 bits), letting a
// certificate holder brute-force small id spaces offline. Now
// HMAC-SHA256 keyed by the operator secret AUDIT_PSEUDONYM_SALT (the
// same secret the audit-log pseudonymizer uses), truncated to 128 bits.
// NOTE: this changes tokens on FUTURE certificates only — certificates
// issued under the old scheme keep their historical value, and rotating
// the key likewise affects only certificates issued afterwards.
const certKey =
process.env["AUDIT_PSEUDONYM_SALT"] ??
"dev-fallback-salt-replace-in-prod";
const certSubjectId =
mode === "cascade-hard"
? `erased-${createHash("sha256").update(subjectId).digest("hex").slice(0, 16)}`
? `erased-${createHmac("sha256", certKey).update(subjectId).digest("hex").slice(0, 32)}`
: subjectId;
return {

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