diff --git a/src/Service/BpnXmlAnonymizer.php b/src/Service/BpnXmlAnonymizer.php index bae67e5..3c010f1 100644 --- a/src/Service/BpnXmlAnonymizer.php +++ b/src/Service/BpnXmlAnonymizer.php @@ -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 , the + * anmelder's , each participant's own - and referential integrity only + * survives if every occurrence of one source value maps to the same replacement. + * + * @var array + */ + private array $personIdReplacements = []; + + /** + * Maps an address id as it appears in the source to its replacement. + * + * @var array + */ + 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 . + $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 $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']); - } + if ('kunde' === $personNode->tagName) { + $this->replaceAttributeValue($personNode, 'id', $identity['customerId']); } } @@ -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 keeps its ids + * at the level, and this is what ties them together. + * + * @param array $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 $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 diff --git a/tests/Service/BpnXmlAnonymizerTest.php b/tests/Service/BpnXmlAnonymizerTest.php index 44f34a4..62cce24 100644 --- a/tests/Service/BpnXmlAnonymizerTest.php +++ b/tests/Service/BpnXmlAnonymizerTest.php @@ -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 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' + + + + Vorgang_Details + F/F/S + + + Alpha + Ada + 111111 + + + Beta + Ben + 222222 + + + Gamma + Cem + 333333 + + + + + + +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' + + + + Vorgang_Details + 162648 + 275987 + + Alpha + Ada + 162648 + 275987 + + + + Alpha + Ada + 275987 + + + Beta + Ben + 413732 + + + +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: carries no ids of its own, so the ones at the + // 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()); } }