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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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