Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/anonymization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, any>>;
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<string, Schema> = { 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<string, any>;
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);
});
});
10 changes: 10 additions & 0 deletions src/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ export const DEFAULT_PII_FIELDS: Record<string, AnonymizationStrategy> = {
'*: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',
Expand Down Expand Up @@ -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',
Expand Down
26 changes: 25 additions & 1 deletion src/entity.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -72,8 +73,10 @@ export const anonymizeEntity = <T extends EntityLike>(params: {
slug?: string;
pseudonymizer: Pseudonymizer;
overrides?: Record<string, AnonymizationStrategy>;
/** 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)) {
Expand All @@ -97,6 +100,24 @@ export const anonymizeEntity = <T extends EntityLike>(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 });
Expand Down Expand Up @@ -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);
}
Expand Down
10 changes: 8 additions & 2 deletions src/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -147,6 +151,7 @@ const transformEntityOperation = (record: Record<string, unknown>, context: Tran
slug: payloadSlug,
pseudonymizer: context.pseudonymizer,
overrides: context.overrides,
nestedSchema: (nestedSlug) => getSchemaFor(context, nestedSlug),
});
}
if (key === 'diff' && isPlainObject(value)) {
Expand All @@ -160,6 +165,7 @@ const transformEntityOperation = (record: Record<string, unknown>, context: Tran
slug: slug ?? '*',
pseudonymizer: context.pseudonymizer,
overrides: context.overrides,
nestedSchema: (nestedSlug) => getSchemaFor(context, nestedSlug),
})
: diffPart,
);
Expand Down
Loading