fix: anonymize the person ids, not the participant slot ids

This commit is contained in:
2026-09-14 15:37:38 +02:00
parent a3e8c737de
commit 91522ab32d
2 changed files with 288 additions and 20 deletions
+163 -13
View File
@@ -11,6 +11,11 @@ class BpnXmlAnonymizer
{
private const SAFE_EMAIL_DOMAIN = 'example.com';
/** Elements holding a person id, in the order resolvePersonId() prefers them. */
private const PERSON_ID_TAGS = ['personid', 'idperson', 'idadresseperson'];
private const ADDRESS_ID_TAG = 'idadresse';
private Generator $faker;
/**
@@ -20,6 +25,24 @@ class BpnXmlAnonymizer
private int $fallbackIdentityCounter = 0;
/**
* Maps a person id as it appears in the source to its replacement.
*
* Person ids show up in several places for the same person - the root <idperson>, the
* anmelder's <idadresseperson>, each participant's own - and referential integrity only
* survives if every occurrence of one source value maps to the same replacement.
*
* @var array<string, string>
*/
private array $personIdReplacements = [];
/**
* Maps an address id as it appears in the source to its replacement.
*
* @var array<string, string>
*/
private array $addressIdReplacements = [];
public function __construct()
{
$this->faker = Factory::create('de_DE');
@@ -46,14 +69,24 @@ class BpnXmlAnonymizer
throw new \RuntimeException('Unable to query person nodes.');
}
// Resolving runs to completion before anything is replaced. resolvePersonId() falls
// back to walking up the ancestors when a node carries no person id of its own, so a
// single interleaved pass could read an id that an earlier replacement had already
// faked and split one person into two identities.
$resolved = [];
foreach ($personNodes as $index => $personNode) {
if (false === $personNode instanceof \DOMElement) {
continue;
}
$personId = $this->resolvePersonId($personNode, (int) $index);
$identity = $this->getOrCreateIdentity($personId);
$identity = $this->getOrCreateIdentity($this->resolvePersonId($personNode, (int) $index));
$this->collectIdReplacements($personNode, $identity);
$resolved[] = [$personNode, $identity];
}
foreach ($resolved as [$personNode, $identity]) {
if ('anfrage' === $personNode->tagName) {
$this->replaceRequestFields($personNode, $identity);
continue;
@@ -67,6 +100,10 @@ class BpnXmlAnonymizer
$this->replaceMetadataFields($personNode, $identity);
}
// Person and address ids are swept document-wide rather than per person node: the
// same ids also appear outside any of them, as direct children of <ergebnis>.
$this->replaceIdReferences($xpath);
$result = $document->saveXML();
if (false === $result) {
throw new \RuntimeException('Unable to serialize anonymized XML.');
@@ -84,6 +121,8 @@ class BpnXmlAnonymizer
{
$this->identities = [];
$this->fallbackIdentityCounter = 0;
$this->personIdReplacements = [];
$this->addressIdReplacements = [];
}
private function resolvePersonId(\DOMElement $personNode, int $nodeIndex): string
@@ -419,16 +458,22 @@ class BpnXmlAnonymizer
}
/**
* Replaces identifying attributes on the person node.
*
* teilnehmer/@id is deliberately left alone: it is not an identifier but the
* participant's 1-based slot within the booking, and zuordnung, status_teilnehmer,
* einzelpreis and the outbound payload all index against it. Rewriting it makes the
* dump self-contradictory - room and service assignments then resolve to nobody - so
* anonymized dumps stop being usable for debugging.
*
* kunde/@id is a real customer id and is replaced.
*
* @param array<string, string> $identity
*/
private function replaceMetadataFields(\DOMElement $personNode, array $identity): void
{
if (true === $personNode->hasAttribute('id')) {
if ('kunde' === $personNode->tagName) {
$this->replaceAttributeValue($personNode, 'id', $identity['customerId']);
} elseif ('teilnehmer' === $personNode->tagName || 'anmelder' === $personNode->tagName) {
$this->replaceAttributeValue($personNode, 'id', $identity['personId']);
}
}
}
@@ -476,15 +521,120 @@ class BpnXmlAnonymizer
$node->setAttribute($attributeName, $replacement);
}
/**
* Records how this person's ids must be replaced wherever they occur.
*
* Every id the node carries itself is claimed. When it carries none, the nearest
* ancestor's is claimed instead - which is the same rule resolvePersonId() used to pick
* this node's identity in the first place, so the two can never disagree about who a
* given id belongs to. A response whose only person sits in <adressdaten> keeps its ids
* at the <ergebnis> level, and this is what ties them together.
*
* @param array<string, string> $identity
*/
private function collectIdReplacements(\DOMElement $personNode, array $identity): void
{
$ownPersonIds = [];
foreach (self::PERSON_ID_TAGS as $tagName) {
$value = $this->getDirectChildValue($personNode, $tagName);
if (null !== $value) {
$ownPersonIds[] = $value;
}
}
if ([] === $ownPersonIds) {
$inherited = $this->findPersonIdInAncestors($personNode);
if (null !== $inherited) {
$ownPersonIds[] = $inherited;
}
}
foreach ($ownPersonIds as $value) {
$this->personIdReplacements[$value] ??= $identity['personId'];
}
$addressId = $this->getDirectChildValue($personNode, self::ADDRESS_ID_TAG)
?? $this->findIdInAncestors($personNode, [self::ADDRESS_ID_TAG]);
if (null !== $addressId) {
$this->addressIdReplacements[$addressId] ??= $identity['addressId'];
}
}
/**
* Replaces every person and address id in the document.
*
* Runs after all person nodes have been resolved, so the lookup keys are the untouched
* source values. An id belonging to nobody the person-node query reached still gets a
* replacement of its own rather than being left in place - it identifies a real person
* either way.
*/
private function replaceIdReferences(\DOMXPath $xpath): void
{
$tagNames = [...self::PERSON_ID_TAGS, self::ADDRESS_ID_TAG];
$nodes = $xpath->query('//'.implode(' | //', $tagNames));
if (false === $nodes) {
return;
}
foreach ($nodes as $node) {
if (false === $node instanceof \DOMElement) {
continue;
}
$value = trim($node->textContent);
if ('' === $value) {
continue;
}
$isAddressId = self::ADDRESS_ID_TAG === $node->tagName;
$replacements = $isAddressId ? $this->addressIdReplacements : $this->personIdReplacements;
if (false === isset($replacements[$value])) {
$identity = $this->getOrCreateIdentity(sprintf('%s:%s', $node->tagName, $value));
$replacements[$value] = $isAddressId ? $identity['addressId'] : $identity['personId'];
if (true === $isAddressId) {
$this->addressIdReplacements[$value] = $replacements[$value];
} else {
$this->personIdReplacements[$value] = $replacements[$value];
}
}
$this->replaceNodeValue($node, $replacements[$value]);
}
}
private function getDirectChildValue(\DOMElement $parent, string $name): ?string
{
$child = $this->getDirectChild($parent, $name);
if (null === $child) {
return null;
}
$value = trim($child->textContent);
return '' === $value ? null : $value;
}
private function findPersonIdInAncestors(\DOMElement $node): ?string
{
foreach (['personid', 'idperson', 'idadresseperson'] as $tagName) {
$child = $this->getDirectChild($node, $tagName);
if (null !== $child) {
$value = trim($child->textContent);
if ('' !== $value) {
return $value;
return $this->findIdInAncestors($node, self::PERSON_ID_TAGS);
}
/**
* Finds the nearest id of the given kind on the node or one of its ancestors.
*
* @param list<string> $tagNames
*/
private function findIdInAncestors(\DOMElement $node, array $tagNames): ?string
{
foreach ($tagNames as $tagName) {
$value = $this->getDirectChildValue($node, $tagName);
if (null !== $value) {
return $value;
}
}
@@ -493,7 +643,7 @@ class BpnXmlAnonymizer
return null;
}
return $this->findPersonIdInAncestors($parent);
return $this->findIdInAncestors($parent, $tagNames);
}
private function getDirectChild(\DOMElement $parent, string $name): ?\DOMElement
+122 -4
View File
@@ -205,11 +205,127 @@ XML;
$this->assertSame('170', $responseHeight);
$this->assertSame('70', $responseWeight);
$this->assertSame('42', $responseShoeSize);
$this->assertSame('123456', $responseRootPersonId);
$this->assertSame('654321', $responseRootAddressId);
// The root ids identify the same person as the <adressdaten> block, so they must be
// replaced too - and with that person's replacements, not fresh ones.
$this->assertSame('700001', $responseRootPersonId);
$this->assertSame('800001', $responseRootAddressId);
$this->assertSame(1, $anonymizer->getLastIdentityCount());
}
public function testAnonymizePreservesParticipantSlotIds(): void
{
$xml = <<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<ergebnis>
<satz typ="KUNDENKONTO" />
<art>Vorgang_Details</art>
<status_teilnehmer>F/F/S</status_teilnehmer>
<teilnehmerliste>
<teilnehmer id="1">
<name>Alpha</name>
<vorname>Ada</vorname>
<idadresseperson>111111</idadresseperson>
</teilnehmer>
<teilnehmer id="2">
<name>Beta</name>
<vorname>Ben</vorname>
<idadresseperson>222222</idadresseperson>
</teilnehmer>
<teilnehmer id="3">
<name>Gamma</name>
<vorname>Cem</vorname>
<idadresseperson>333333</idadresseperson>
</teilnehmer>
</teilnehmerliste>
<ferienzielunterbringungen>
<ferienzielunterbringung idzimmer="74" anzahl="3" zuordnung="1,2,3" einzelpreis="439,00/439,00/439,00" />
</ferienzielunterbringungen>
</ergebnis>
XML;
$anonymizer = new BpnXmlAnonymizer();
$result = $anonymizer->anonymize($xml);
$document = new \DOMDocument();
$this->assertTrue($document->loadXML($result));
$xpath = new \DOMXPath($document);
// Names must be replaced - this is still an anonymizer.
$this->assertNotSame('Alpha', trim((string) $xpath->evaluate('string(//teilnehmer[1]/name)')));
// Slot ids must survive: zuordnung, status_teilnehmer and einzelpreis all index
// against them, so rewriting them would make the dump self-contradictory.
$this->assertSame('1', trim((string) $xpath->evaluate('string(//teilnehmer[1]/@id)')));
$this->assertSame('2', trim((string) $xpath->evaluate('string(//teilnehmer[2]/@id)')));
$this->assertSame('3', trim((string) $xpath->evaluate('string(//teilnehmer[3]/@id)')));
$this->assertSame('1,2,3', trim((string) $xpath->evaluate('string(//ferienzielunterbringung/@zuordnung)')));
$this->assertSame('F/F/S', trim((string) $xpath->evaluate('string(//status_teilnehmer)')));
}
public function testAnonymizeReplacesPersonAndAddressIdsConsistently(): void
{
$xml = <<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<ergebnis>
<satz typ="KUNDENKONTO" />
<art>Vorgang_Details</art>
<idadresse>162648</idadresse>
<idperson>275987</idperson>
<anmelder>
<name>Alpha</name>
<vorname>Ada</vorname>
<idadresse>162648</idadresse>
<idadresseperson>275987</idadresseperson>
</anmelder>
<teilnehmerliste>
<teilnehmer id="1">
<name>Alpha</name>
<vorname>Ada</vorname>
<idadresseperson>275987</idadresseperson>
</teilnehmer>
<teilnehmer id="2">
<name>Beta</name>
<vorname>Ben</vorname>
<idadresseperson>413732</idadresseperson>
</teilnehmer>
</teilnehmerliste>
</ergebnis>
XML;
$anonymizer = new BpnXmlAnonymizer();
$result = $anonymizer->anonymize($xml);
$this->assertStringNotContainsString('275987', $result);
$this->assertStringNotContainsString('413732', $result);
$this->assertStringNotContainsString('162648', $result);
$document = new \DOMDocument();
$this->assertTrue($document->loadXML($result));
$xpath = new \DOMXPath($document);
$rootPersonId = trim((string) $xpath->evaluate('string(/ergebnis/idperson)'));
$rootAddressId = trim((string) $xpath->evaluate('string(/ergebnis/idadresse)'));
$applicantPersonId = trim((string) $xpath->evaluate('string(//anmelder/idadresseperson)'));
$applicantAddressId = trim((string) $xpath->evaluate('string(//anmelder/idadresse)'));
$firstPersonId = trim((string) $xpath->evaluate('string(//teilnehmer[1]/idadresseperson)'));
$secondPersonId = trim((string) $xpath->evaluate('string(//teilnehmer[2]/idadresseperson)'));
// One source id maps to one replacement wherever it occurs, so the applicant stays
// recognisable as participant 1 and as the owner of the root ids.
$this->assertSame($applicantPersonId, $rootPersonId);
$this->assertSame($applicantPersonId, $firstPersonId);
$this->assertSame($applicantAddressId, $rootAddressId);
// Distinct people keep distinct ids.
$this->assertNotSame($applicantPersonId, $secondPersonId);
// The applicant and participant 1 are one person, so only two identities exist.
$this->assertSame(2, $anonymizer->getLastIdentityCount());
}
public function testAnonymizeReplacesKundeAttributes(): void
{
$xml = <<<'XML'
@@ -526,8 +642,10 @@ XML;
$this->assertSame('170', $height);
$this->assertSame('70', $weight);
$this->assertSame('42', $shoeSize);
$this->assertSame('123456', $personId);
$this->assertSame('654321', $addressIdRoot);
// Same here: <adressdaten> carries no ids of its own, so the ones at the <ergebnis>
// level are its own and must map onto this person's replacements.
$this->assertSame('700001', $personId);
$this->assertSame('800001', $addressIdRoot);
$this->assertSame(1, $anonymizer->getLastIdentityCount());
}
}