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 <noreply@anthropic.com>
104 lines
2.8 KiB
TypeScript
104 lines
2.8 KiB
TypeScript
import type { CollectionConfig } from "payload";
|
|
|
|
export const users: CollectionConfig = {
|
|
slug: "users",
|
|
auth: true,
|
|
admin: {
|
|
useAsTitle: "email",
|
|
},
|
|
custom: {
|
|
retention: {
|
|
purgeSchedule: "daily",
|
|
postDeletion: {
|
|
duration: "P30D",
|
|
trigger: "after-deletion",
|
|
action: "hard-delete",
|
|
},
|
|
},
|
|
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: [
|
|
{
|
|
// Read/written by the production UsersRepository (getUserByUsername,
|
|
// createUser). Pinned by collections/users.test.ts against
|
|
// USERS_REPOSITORY_FIELDS so repo <-> collection drift fails fast.
|
|
name: "username",
|
|
type: "text",
|
|
required: true,
|
|
unique: true,
|
|
index: true,
|
|
custom: {
|
|
pii: {
|
|
category: "identification-username",
|
|
purpose: ["service-delivery"],
|
|
exportable: true,
|
|
restrictable: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
// Credential material — must never leave the server. `access.read`
|
|
// returns false unconditionally so the field is stripped from every
|
|
// REST/GraphQL/admin API response; the auth repository still reads it
|
|
// through the local API with `overrideAccess: true`.
|
|
name: "passwordHash",
|
|
type: "text",
|
|
required: true,
|
|
admin: { hidden: true },
|
|
access: { read: () => false },
|
|
},
|
|
{
|
|
name: "displayName",
|
|
type: "text",
|
|
custom: {
|
|
pii: {
|
|
category: "identification-username",
|
|
purpose: ["service-delivery"],
|
|
exportable: true,
|
|
restrictable: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "role",
|
|
type: "select",
|
|
options: [
|
|
{ label: "Admin", value: "admin" },
|
|
{ label: "Editor", value: "editor" },
|
|
{ label: "Author", value: "author" },
|
|
],
|
|
defaultValue: "author",
|
|
required: true,
|
|
},
|
|
{
|
|
name: "consentState",
|
|
type: "json",
|
|
},
|
|
],
|
|
};
|