From 7b0c2ea5906f9be2fc29a47feaf6284aedce3f64 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Fri, 10 Jul 2026 18:05:09 +0200 Subject: [PATCH] fix(auth): declare users email/username/displayName in DSR pii map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DSR walkers read the COLLECTION-level custom.pii map, which the users collection never declared — Art. 15 export returned bare ids and Art. 17 soft delete redacted nothing; the auth-injected email field in particular was invisible (audit finding A5). Declares email (auto-added by Payload auth: true), username and displayName as exportable + restrictable; walker tests pin a users-shaped collection end to end. Co-Authored-By: Claude Fable 5 --- .../src/integrations/cms/collections/users.ts | 25 +++++++++ .../src/__tests__/payload-data-delete.test.ts | 51 ++++++++++++++++++ .../src/__tests__/payload-data-export.test.ts | 54 +++++++++++++++++++ 3 files changed, 130 insertions(+) diff --git a/packages/auth/src/integrations/cms/collections/users.ts b/packages/auth/src/integrations/cms/collections/users.ts index 61129e6..36190cf 100644 --- a/packages/auth/src/integrations/cms/collections/users.ts +++ b/packages/auth/src/integrations/cms/collections/users.ts @@ -16,6 +16,31 @@ export const users: CollectionConfig = { }, }, subject: { kind: "self", field: "id" }, + // Collection-level PII map consumed by the DSR walkers (audit finding + // A5): export includes fields marked exportable; the soft-delete path + // redacts them. `email` is auto-added by Payload's `auth: true` and has + // no explicit field entry below, so it MUST be declared here or Art. 15 + // export misses it and Art. 17 soft delete leaves it behind. + pii: { + email: { + category: "contact-email", + purpose: ["account-authentication", "transactional-notifications"], + 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, + }, + }, }, fields: [ { diff --git a/packages/core-dsr/src/__tests__/payload-data-delete.test.ts b/packages/core-dsr/src/__tests__/payload-data-delete.test.ts index d8596d5..908d36f 100644 --- a/packages/core-dsr/src/__tests__/payload-data-delete.test.ts +++ b/packages/core-dsr/src/__tests__/payload-data-delete.test.ts @@ -192,6 +192,57 @@ describe("PayloadDataDelete", () => { 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([ { diff --git a/packages/core-dsr/src/__tests__/payload-data-export.test.ts b/packages/core-dsr/src/__tests__/payload-data-export.test.ts index e0de423..b11f67e 100644 --- a/packages/core-dsr/src/__tests__/payload-data-export.test.ts +++ b/packages/core-dsr/src/__tests__/payload-data-export.test.ts @@ -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([ {