fix: anonymize the person ids, not the participant slot ids
This commit is contained in:
@@ -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']);
|
||||
}
|
||||
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 <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
|
||||
|
||||
Reference in New Issue
Block a user