diff --git a/src/anonymization.test.ts b/src/anonymization.test.ts index 0fec26c..d56a9f4 100644 --- a/src/anonymization.test.ts +++ b/src/anonymization.test.ts @@ -423,3 +423,73 @@ describe('message entity from/to names', () => { expect(out.from.name).toBe('Berlin'); }); }); + +describe('submission entities[] (nested entities without _id)', () => { + const submission = { + _id: 's1', + _org: '123', + _schema: 'submission', + _title: 's1', + source_type: 'journey', + entities: [ + { + _schema: 'contact', + first_name: 'Erika', + last_name: 'Mustermann', + salutation: 'Frau', + birthdate: '1985-04-12', + email: [{ email: 'erika@example.com' }], + phone: [{ phone: '+49 221 1234567' }], + address: [{ city: 'Köln', country: 'DE', postal_code: '50668', street: 'Domstraße', street_number: '12' }], + payment: [ + { + type: 'payment_sepa', + data: { bank_name: 'Commerzbank', fullname: 'Erika Mustermann', iban: 'DE89370400440532013000' }, + }, + ], + }, + { _schema: 'opportunity', description: 'Bitte Rückruf vormittags', title: 'PV 10 kWp' }, + ], + }; + + const expectMasked = (result: typeof submission) => { + const [contactOut, opportunityOut] = result.entities as Array>; + const json = JSON.stringify(result); + for (const leaked of ['Erika', 'Mustermann', 'Domstraße', '1985-04', 'Frau', '1234567', 'DE89370400440532013000']) { + expect(json).not.toContain(leaked); + } + expect(contactOut.first_name).toMatch(/^person_[0-9a-f]{8}$/); + expect(contactOut.birthdate).toBe('1985-01-01'); + expect(contactOut.address[0]).toMatchObject({ city: 'Köln', postal_code: '50668' }); + expect(contactOut.payment[0].data).toMatchObject({ bank_name: 'Commerzbank' }); + expect(opportunityOut.description).toBe('[REDACTED]'); + expect(opportunityOut.title).toBe('PV 10 kWp'); + }; + + it('anonymizes nested entities with curated defaults (no schemas)', async () => { + expectMasked(await anonymizer.anonymizeResponse(submission)); + }); + + it('anonymizes nested entities with their resolved schemas', async () => { + const schemas: Record = { contact: contactSchema, submission: { slug: 'submission' } }; + expectMasked(await anonymizer.anonymizeResponse(submission, { schemaResolver: ({ slug }) => schemas[slug] ?? null })); + }); + + it('anonymizeEntity handles a submission directly', () => { + expectMasked(anonymizer.anonymizeEntity({ entity: submission })); + }); + + it('pseudonymizes a nested contact exactly like a standalone contact (joinable, no double hash)', async () => { + const standalone = anonymizer.anonymizeEntity({ + entity: { _id: 'c1', _org: '123', _schema: 'contact', ...submission.entities[0] }, + schema: contactSchema, + }); + const result = (await anonymizer.anonymizeResponse(submission, { + schemaResolver: ({ slug }) => (slug === 'contact' ? contactSchema : null), + })) as typeof submission; + const nested = result.entities[0] as Record; + expect(nested.first_name).toBe((standalone as any).first_name); + expect(nested.email).toEqual((standalone as any).email); + expect(nested.payment[0].data.iban).toBe((standalone as any).payment[0].data.iban); + }); +}); diff --git a/src/defaults.ts b/src/defaults.ts index bbd2bf7..aff4cb6 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -46,6 +46,13 @@ export const DEFAULT_PII_FIELDS: Record = { '*:salutation': 'redact', '*:birthdate': 'date_year', '*:date_of_birth': 'date_year', + // structured PII by field name — safety net when the schema is unavailable (e.g. entities nested + // in a submission); type-based classification (`address`, `payment`) covers custom-named ones + '*:address': 'address', + '*:payment': 'payment', + // journey-submitted contact fields on the submission entity itself + 'submission:contact_first_name': 'person', + 'submission:contact_last_name': 'person', // common identifier field names on any schema (also covers plain string-typed variants) '*:email': 'email', '*:phone': 'phone', @@ -135,6 +142,9 @@ export const ADDRESS_KEEP_KEYS = ['_id', '_tags', 'postal_code', 'zip', 'city', /** Keys on payment items whose values identify a person/account and must be pseudonymized */ export const PAYMENT_PSEUDONYMIZE_KEY_PATTERN = /iban|account|holder|owner|card|number/i; +/** Keys on payment items holding a person's name (`fullname`, `first_name`, …); `bank_name` is kept */ +export const PAYMENT_PERSON_KEY_PATTERN = /(^|_)(full|first|last)_?name$/i; + /** Keys never anonymized on any object (system metadata, not PII) */ export const SYSTEM_KEEP_KEYS = [ '_id', diff --git a/src/entity.ts b/src/entity.ts index 579ada2..4fc932c 100644 --- a/src/entity.ts +++ b/src/entity.ts @@ -1,6 +1,7 @@ import { ADDRESS_KEEP_KEYS, DEFAULT_PII_FIELDS, + PAYMENT_PERSON_KEY_PATTERN, PAYMENT_PSEUDONYMIZE_KEY_PATTERN, STRATEGY_BY_ATTRIBUTE_TYPE, SYSTEM_KEEP_KEYS, @@ -72,8 +73,10 @@ export const anonymizeEntity = (params: { slug?: string; pseudonymizer: Pseudonymizer; overrides?: Record; + /** resolves the schema of an entity nested in a `submission` (`entities[]`); null = curated defaults only */ + nestedSchema?: (slug: string) => Schema | null | undefined; }): T => { - const { entity, schema, pseudonymizer, overrides } = params; + const { entity, schema, pseudonymizer, overrides, nestedSchema } = params; const slug = params.slug ?? (entity?._schema as string) ?? schema?.slug ?? '*'; if (!isPlainObject(entity)) { @@ -97,6 +100,24 @@ export const anonymizeEntity = (params: { continue; } + // a submission carries the entities captured by the journey (contact, opportunity, …); + // they have a `_schema` but no `_id`/`_org`, so the generic entity walker never sees them + if (slug === 'submission' && key === 'entities' && Array.isArray(value)) { + result[key] = value.map((item) => + isPlainObject(item) && typeof item._schema === 'string' + ? anonymizeEntity({ + entity: item, + schema: nestedSchema?.(item._schema), + slug: item._schema, + pseudonymizer, + overrides, + nestedSchema, + }) + : mapStringLeaves(item, (str) => pseudonymizer.scrubText(str)), + ); + continue; + } + const attribute = key === '_title' ? undefined : attributesByName.get(key); // _title is computed from attributes and can carry PII (e.g. contact names) const strategy = getAttributeStrategy({ slug, attributeName: key, attribute, overrides }); @@ -282,6 +303,9 @@ const anonymizePaymentItem = (item: unknown, pseudonymizer: Pseudonymizer): unkn if (isPlainObject(val) || Array.isArray(val)) { return anonymizePaymentItem(val, pseudonymizer); } + if (typeof val === 'string' && PAYMENT_PERSON_KEY_PATTERN.test(key)) { + return pseudonymizer.person(val); + } if (typeof val === 'string' && PAYMENT_PSEUDONYMIZE_KEY_PATTERN.test(key)) { return key.toLowerCase().includes('iban') ? pseudonymizer.iban(val) : pseudonymizer.value(val); } diff --git a/src/response.ts b/src/response.ts index e315071..6515196 100644 --- a/src/response.ts +++ b/src/response.ts @@ -115,10 +115,14 @@ const transformNode = (node: unknown, context: TransformContext): unknown => { slug, pseudonymizer: context.pseudonymizer, overrides: context.overrides, + nestedSchema: (nestedSlug) => getSchemaFor(context, nestedSlug, node._org as string | undefined), }); - // recurse to catch hydrated relations and other nested entities - return mapEntries(anonymized, (value) => transformNode(value, context)); + // recurse to catch hydrated relations and other nested entities; a submission's + // entities[] are already anonymized (re-walking them would pseudonymize twice) + return mapEntries(anonymized, (value, key) => + slug === 'submission' && key === 'entities' ? value : transformNode(value, context), + ); } if (isEntityOperationLike(node)) { @@ -147,6 +151,7 @@ const transformEntityOperation = (record: Record, context: Tran slug: payloadSlug, pseudonymizer: context.pseudonymizer, overrides: context.overrides, + nestedSchema: (nestedSlug) => getSchemaFor(context, nestedSlug), }); } if (key === 'diff' && isPlainObject(value)) { @@ -160,6 +165,7 @@ const transformEntityOperation = (record: Record, context: Tran slug: slug ?? '*', pseudonymizer: context.pseudonymizer, overrides: context.overrides, + nestedSchema: (nestedSlug) => getSchemaFor(context, nestedSlug), }) : diffPart, );