diff --git a/src/Command/DbAnonymizeCommand.php b/src/Command/DbAnonymizeCommand.php new file mode 100644 index 0000000..4463a31 --- /dev/null +++ b/src/Command/DbAnonymizeCommand.php @@ -0,0 +1,62 @@ +environment) { + $io->error('The database anonymizer is disabled in the prod environment.'); + + return Command::FAILURE; + } + + try { + $report = $this->databaseAnonymizer->anonymizeAll(); + } catch (\Throwable $exception) { + $this->logger->error('Database anonymization failed', [ + 'environment' => $this->environment, + 'error' => $exception->getMessage(), + ]); + + $io->error($exception->getMessage()); + + return Command::FAILURE; + } + + $io->success(sprintf( + 'Anonymized %d users, %d newsletter consents, %d newsletter opt-in requests, and %d booking drafts.', + $report['users'], + $report['newsletterConsents'], + $report['newsletterOptInRequests'], + $report['bookingEditDrafts'], + )); + + return Command::SUCCESS; + } +} diff --git a/src/Entity/BookingEditDraft.php b/src/Entity/BookingEditDraft.php index 3fbbd4a..e6dcdce 100644 --- a/src/Entity/BookingEditDraft.php +++ b/src/Entity/BookingEditDraft.php @@ -164,6 +164,20 @@ class BookingEditDraft return $this; } + /** + * Replaces the form data without touching timestamps. + * + * Used by anonymization tooling that must not alter audit dates. + * + * @param array $formData + */ + public function replaceFormData(array $formData): static + { + $this->formData = $formData; + + return $this; + } + public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; diff --git a/src/Entity/NewsletterConsent.php b/src/Entity/NewsletterConsent.php index 008804b..8745ed4 100644 --- a/src/Entity/NewsletterConsent.php +++ b/src/Entity/NewsletterConsent.php @@ -65,6 +65,11 @@ class NewsletterConsent return $this->email; } + public function setEmail(string $email): void + { + $this->email = mb_strtolower(trim($email)); + } + public function getMailjetListId(): int { return $this->mailjetListId; @@ -90,6 +95,16 @@ class NewsletterConsent return $this->lastName; } + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): \DateTimeImmutable + { + return $this->updatedAt; + } + public function isConfirmed(): bool { return null !== $this->confirmedAt && null === $this->revokedAt; diff --git a/src/Entity/NewsletterOptInRequest.php b/src/Entity/NewsletterOptInRequest.php index 3cf920a..0f87339 100644 --- a/src/Entity/NewsletterOptInRequest.php +++ b/src/Entity/NewsletterOptInRequest.php @@ -76,6 +76,11 @@ class NewsletterOptInRequest return $this->email; } + public function setEmail(string $email): void + { + $this->email = mb_strtolower(trim($email)); + } + public function getTokenHash(): string { return $this->tokenHash; diff --git a/src/Service/DatabaseAnonymizer.php b/src/Service/DatabaseAnonymizer.php new file mode 100644 index 0000000..ca7ed11 --- /dev/null +++ b/src/Service/DatabaseAnonymizer.php @@ -0,0 +1,445 @@ +> + */ + private array $identityMap = []; + + private int $identitySequence = 0; + + private readonly Generator $faker; + + public function __construct( + private readonly EntityManagerInterface $entityManager, + private readonly LoggerInterface $logger, + ) { + $this->faker = Factory::create('de_DE'); + } + + public function resetState(): void + { + $this->identityMap = []; + $this->identitySequence = 0; + } + + public function anonymizeAll(int $batchSize = self::DEFAULT_BATCH_SIZE): array + { + if ($batchSize < 1) { + throw new \InvalidArgumentException('Batch size must be at least 1.'); + } + + $this->resetState(); + + $report = [ + 'users' => $this->anonymizeQuery( + 'SELECT u FROM App\Entity\User u ORDER BY u.id ASC', + fn (User $user) => $this->anonymizeUser($user), + $batchSize, + ), + 'newsletterConsents' => $this->anonymizeQuery( + 'SELECT c FROM App\Entity\NewsletterConsent c ORDER BY c.id ASC', + fn (NewsletterConsent $consent) => $this->anonymizeNewsletterConsent($consent), + $batchSize, + ), + 'newsletterOptInRequests' => $this->anonymizeQuery( + 'SELECT r FROM App\Entity\NewsletterOptInRequest r ORDER BY r.id ASC', + fn (NewsletterOptInRequest $request) => $this->anonymizeNewsletterOptInRequest($request), + $batchSize, + ), + 'bookingEditDrafts' => $this->anonymizeQuery( + 'SELECT d FROM App\Entity\BookingEditDraft d ORDER BY d.id ASC', + fn (BookingEditDraft $draft) => $this->anonymizeBookingEditDraft($draft), + $batchSize, + ), + ]; + + $this->entityManager->flush(); + + $this->logger->info('Anonymized local database records', $report); + + return $report; + } + + public function anonymizeUser(User $user): void + { + $identity = $this->resolveIdentity( + email: $user->getEmail(), + ); + + $user->setEmail($identity['email']); + } + + public function anonymizeNewsletterConsent(NewsletterConsent $consent): void + { + $identity = $this->resolveIdentity( + email: $consent->getEmail(), + firstName: $consent->getFirstName(), + lastName: $consent->getLastName(), + ); + + $consent->setEmail($identity['email']); + $consent->setNames( + $this->shouldReplaceName($consent->getFirstName()) ? $identity['firstName'] : $consent->getFirstName(), + $this->shouldReplaceName($consent->getLastName()) ? $identity['lastName'] : $consent->getLastName(), + ); + } + + public function anonymizeNewsletterOptInRequest(NewsletterOptInRequest $request): void + { + $identity = $this->resolveIdentity( + email: $request->getEmail(), + firstName: $request->getFirstName(), + lastName: $request->getLastName(), + ); + + $request->setEmail($identity['email']); + $request->setNames( + $this->shouldReplaceName($request->getFirstName()) ? $identity['firstName'] : $request->getFirstName(), + $this->shouldReplaceName($request->getLastName()) ? $identity['lastName'] : $request->getLastName(), + ); + } + + public function anonymizeBookingEditDraft(BookingEditDraft $draft): void + { + $formData = $draft->getFormData(); + $formData = $this->anonymizeDraftFormData($formData); + + $draft->replaceFormData($formData); + } + + /** + * @param callable(object):void $anonymize + */ + private function anonymizeQuery(string $dql, callable $anonymize, int $batchSize): int + { + $query = $this->entityManager->createQuery($dql); + $processed = 0; + $pendingFlush = 0; + + foreach ($query->toIterable() as $entity) { + $anonymize($entity); + ++$processed; + ++$pendingFlush; + + if (0 === $pendingFlush % $batchSize) { + $this->entityManager->flush(); + $this->entityManager->clear(); + $pendingFlush = 0; + } + } + + if ($pendingFlush > 0) { + $this->entityManager->flush(); + $this->entityManager->clear(); + } + + return $processed; + } + + /** + * @param array $formData + * + * @return array + */ + private function anonymizeDraftFormData(array $formData): array + { + if (isset($formData['bankAccount']) && true === is_array($formData['bankAccount'])) { + $formData['bankAccount'] = $this->anonymizeBankAccountData($formData['bankAccount']); + } + + if (isset($formData['participants']) && true === is_array($formData['participants'])) { + foreach ($formData['participants'] as $index => $participantData) { + if (false === is_array($participantData)) { + continue; + } + + $formData['participants'][$index] = $this->anonymizeParticipantData($participantData); + } + } + + return $formData; + } + + /** + * @param array $participantData + * + * @return array + */ + private function anonymizeParticipantData(array $participantData): array + { + $identity = $this->resolveIdentity( + email: $this->stringOrNull($participantData['personalData']['email'] ?? null), + firstName: $this->stringOrNull($participantData['personalData']['firstName'] ?? null), + lastName: $this->stringOrNull($participantData['personalData']['lastName'] ?? null), + fullName: $this->stringOrNull($participantData['personalData']['name'] ?? null), + fallback: $this->stringOrNull($participantData['licensePlate'] ?? null), + ); + + if (isset($participantData['personalData']) && true === is_array($participantData['personalData'])) { + $participantData['personalData'] = $this->anonymizePersonalData($participantData['personalData'], $identity); + } + + if (isset($participantData['address']) && true === is_array($participantData['address'])) { + $participantData['address'] = $this->anonymizeAddressData($participantData['address'], $identity); + } + + if (isset($participantData['roomAssignment']) && true === is_array($participantData['roomAssignment'])) { + if (array_key_exists('remarksRoom', $participantData['roomAssignment'])) { + $participantData['roomAssignment']['remarksRoom'] = $this->placeholderText($identity, 'remarks'); + } + } + + if (array_key_exists('licensePlate', $participantData)) { + $participantData['licensePlate'] = $this->placeholderText($identity, 'plate'); + } + + if (isset($participantData['bankAccount']) && true === is_array($participantData['bankAccount'])) { + $participantData['bankAccount'] = $this->anonymizeBankAccountData($participantData['bankAccount'], $identity); + } + + return $participantData; + } + + /** + * @param array $personalData + * @param array $identity + * + * @return array + */ + private function anonymizePersonalData(array $personalData, array $identity): array + { + if (array_key_exists('firstName', $personalData) && null !== $personalData['firstName']) { + $personalData['firstName'] = $identity['firstName']; + } + + if (array_key_exists('lastName', $personalData) && null !== $personalData['lastName']) { + $personalData['lastName'] = $identity['lastName']; + } + + if (array_key_exists('email', $personalData) && null !== $personalData['email']) { + $personalData['email'] = $identity['email']; + } + + if (array_key_exists('mobile', $personalData) && null !== $personalData['mobile']) { + $personalData['mobile'] = $identity['mobile']; + } + + if (array_key_exists('phone', $personalData) && null !== $personalData['phone']) { + $personalData['phone'] = $identity['phone']; + } + + if (array_key_exists('name', $personalData) && null !== $personalData['name']) { + $personalData['name'] = $identity['lastName']; + } + + return $personalData; + } + + /** + * @param array $addressData + * @param array $identity + * + * @return array + */ + private function anonymizeAddressData(array $addressData, array $identity): array + { + if (array_key_exists('street', $addressData) && null !== $addressData['street']) { + $addressData['street'] = $identity['street']; + } + + if (array_key_exists('postCode', $addressData) && null !== $addressData['postCode']) { + $addressData['postCode'] = $identity['postCode']; + } + + if (array_key_exists('city', $addressData) && null !== $addressData['city']) { + $addressData['city'] = $identity['city']; + } + + if (array_key_exists('country', $addressData) && null !== $addressData['country']) { + $addressData['country'] = $identity['country']; + } + + if (array_key_exists('district', $addressData) && null !== $addressData['district']) { + $addressData['district'] = $identity['district']; + } + + return $addressData; + } + + /** + * @param array $bankAccountData + * @param array|null $identity + * + * @return array + */ + private function anonymizeBankAccountData(array $bankAccountData, ?array $identity = null): array + { + $identity ??= $this->resolveIdentity( + fullName: $this->stringOrNull($bankAccountData['accountHolder'] ?? null), + ); + + if (array_key_exists('iban', $bankAccountData) && null !== $bankAccountData['iban']) { + $bankAccountData['iban'] = $identity['iban']; + } + + if (array_key_exists('accountHolder', $bankAccountData) && null !== $bankAccountData['accountHolder']) { + $bankAccountData['accountHolder'] = $identity['accountHolder']; + } + + if (array_key_exists('bankName', $bankAccountData) && null !== $bankAccountData['bankName']) { + $bankAccountData['bankName'] = $identity['bankName']; + } + + if (array_key_exists('bic', $bankAccountData) && null !== $bankAccountData['bic']) { + $bankAccountData['bic'] = $identity['bic']; + } + + return $bankAccountData; + } + + /** + * @return array + */ + private function resolveIdentity(?string $email = null, ?string $firstName = null, ?string $lastName = null, ?string $fullName = null, ?string $fallback = null): array + { + $aliases = []; + + foreach ([ + $this->normalizeIdentityKey('email', $email), + $this->normalizeIdentityKey('name', null !== $fullName ? $fullName : trim(($firstName ?? '').' '.($lastName ?? ''))), + $this->normalizeIdentityKey('fallback', $fallback), + ] as $alias) { + if (null !== $alias) { + $aliases[] = $alias; + } + } + + foreach ($aliases as $alias) { + if (isset($this->identityMap[$alias])) { + $identity = $this->identityMap[$alias]; + foreach ($aliases as $candidateAlias) { + $this->identityMap[$candidateAlias] = $identity; + } + + return $identity; + } + } + + $identity = $this->createIdentity(); + foreach ($aliases as $alias) { + $this->identityMap[$alias] = $identity; + } + + return $identity; + } + + /** + * @return array + */ + private function createIdentity(): array + { + ++$this->identitySequence; + $sequence = $this->identitySequence; + + $firstName = $this->faker->firstName(); + $lastName = $this->faker->lastName(); + $emailLocal = sprintf('%s.%s.%04d', $this->slug($firstName), $this->slug($lastName), $sequence); + + return [ + 'firstName' => $firstName, + 'lastName' => $lastName, + 'email' => sprintf('%s@%s', $emailLocal, self::SYNTHETIC_EMAIL_DOMAIN), + 'mobile' => $this->faker->numerify('01#########'), + 'phone' => $this->faker->numerify('0##########'), + 'street' => sprintf('%s %s', $this->faker->streetName(), $this->faker->buildingNumber()), + 'postCode' => $this->faker->postcode(), + 'city' => $this->faker->city(), + 'country' => self::SYNTHETIC_COUNTRY, + 'district' => $this->faker->citySuffix(), + 'accountHolder' => $firstName.' '.$lastName, + 'bankName' => $this->faker->company(), + 'iban' => self::SYNTHETIC_IBAN, + 'bic' => 'GENODEF1SYN', + 'licensePlate' => sprintf('SYN-%05d', $sequence), + ]; + } + + private function normalizeIdentityKey(string $type, ?string $value): ?string + { + $normalized = $this->stringOrNull($value); + if (null === $normalized) { + return null; + } + + return $type.':'.mb_strtolower($normalized); + } + + private function shouldReplaceName(?string $value): bool + { + return null !== $this->stringOrNull($value); + } + + private function placeholderText(array $identity, string $suffix): string + { + return sprintf('%s-%s', $identity['lastName'], $suffix); + } + + private function stringOrNull(mixed $value): ?string + { + if (null === $value) { + return null; + } + + if (false === is_string($value)) { + return trim((string) $value); + } + + $trimmed = trim($value); + + return '' === $trimmed ? null : $trimmed; + } + + private function slug(string $value): string + { + $normalizedValue = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value); + if (false === $normalizedValue) { + $normalizedValue = $value; + } + + $normalizedValue = strtolower($normalizedValue); + $normalizedValue = preg_replace('/[^a-z0-9]+/', '.', $normalizedValue); + $normalizedValue = trim((string) $normalizedValue, '.'); + + if ('' === $normalizedValue) { + return 'user'; + } + + return $normalizedValue; + } +} diff --git a/tests/Command/DbAnonymizeCommandTest.php b/tests/Command/DbAnonymizeCommandTest.php new file mode 100644 index 0000000..a5db6ad --- /dev/null +++ b/tests/Command/DbAnonymizeCommandTest.php @@ -0,0 +1,55 @@ +createMock(DatabaseAnonymizer::class); + $anonymizer->expects(self::never())->method('anonymizeAll'); + + $tester = new CommandTester($this->createCommand($anonymizer, 'prod')); + $tester->execute([]); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('disabled in the prod environment', $tester->getDisplay()); + } + + public function testCommandDelegatesOutsideProdEnvironment(): void + { + $anonymizer = $this->createMock(DatabaseAnonymizer::class); + $anonymizer->expects(self::once()) + ->method('anonymizeAll') + ->willReturn([ + 'users' => 1, + 'newsletterConsents' => 2, + 'newsletterOptInRequests' => 3, + 'bookingEditDrafts' => 4, + ]); + + $tester = new CommandTester($this->createCommand($anonymizer, 'dev')); + $tester->execute([]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('Anonymized 1 users, 2 newsletter consents, 3 newsletter opt-in requests, and 4 booking drafts.', $tester->getDisplay()); + } + + private function createCommand(DatabaseAnonymizer $anonymizer, string $environment): DbAnonymizeCommand + { + return new DbAnonymizeCommand( + $anonymizer, + $this->createMock(LoggerInterface::class), + $environment, + ); + } +} diff --git a/tests/Service/DatabaseAnonymizerTest.php b/tests/Service/DatabaseAnonymizerTest.php new file mode 100644 index 0000000..779fd6e --- /dev/null +++ b/tests/Service/DatabaseAnonymizerTest.php @@ -0,0 +1,190 @@ +createMock(EntityManagerInterface::class), + new NullLogger(), + ); + + $user = new User('Mia.Muster@example.com'); + $user->setPassword('plain-text-password'); + $user->setPersonId(12345); + $user->setAddressId(67890); + $user->setRoles(['ROLE_ADMIN']); + $user->setHotelCodes(['ABC']); + $lastLoginAt = new \DateTimeImmutable('2026-06-01 10:00:00'); + $user->setLastLoginAt($lastLoginAt); + $user->setProfileComplete(true); + + $consent = new NewsletterConsent('Mia.Muster@example.com', 42, 'Mia', 'Muster'); + $consent->markConfirmed(new \DateTimeImmutable('2026-06-02 11:00:00')); + + $request = new NewsletterOptInRequest( + 'Mia.Muster@example.com', + str_repeat('a', 64), + new \DateTimeImmutable('2026-07-01 12:00:00'), + [11, 22], + 'Mia', + 'Muster', + ); + $request->markConfirmed(new \DateTimeImmutable('2026-06-03 13:00:00')); + + $draft = new BookingEditDraft($user, 999, new \DateTimeImmutable('2026-08-15'), [ + 'paymentMethod' => 'debit', + 'bankAccount' => [ + 'iban' => 'DE02123412341234123412', + 'accountHolder' => 'Mia Muster', + 'bankName' => 'Example Bank', + 'sepaMandateAccepted' => true, + ], + 'participants' => [ + 0 => [ + 'personalData' => [ + 'firstName' => 'Mia', + 'lastName' => 'Muster', + 'dateOfBirth' => '1990-05-15', + 'email' => 'Mia.Muster@example.com', + 'mobile' => '+49123456789', + 'gender' => 'W', + 'nationality' => 'DE', + ], + 'address' => [ + 'street' => 'Main Street 1', + 'postCode' => '12345', + 'city' => 'Berlin', + 'country' => 'DE', + 'district' => 'Mitte', + ], + 'bodyDimensions' => [ + 'height' => '180', + 'weight' => '70', + 'shoeSize' => '42', + ], + 'roomAssignment' => [ + 'assignedRoomId' => 7, + 'remarksRoom' => 'Window please', + ], + 'licensePlate' => 'B-AB-1234', + 'services' => [ + 'skiPass' => 10, + 'courses' => [11], + 'board' => [12], + 'rentals' => [13], + 'rentalInsurance' => 14, + 'additionalServices' => [15], + 'transportationOutbound' => 16, + 'transportationInbound' => 17, + 'pickup' => 18, + 'dropOff' => 19, + 'parking' => true, + 'insurance' => 20, + 'bulkInsuranceBooking' => false, + ], + 'vouchers' => [ + 'purchaseVoucherCode' => 'PURCHASE-123', + 'promoVoucherCode' => 'PROMO-456', + ], + ], + 1 => [ + 'personalData' => [ + 'firstName' => 'Mia', + 'lastName' => 'Muster', + 'dateOfBirth' => '1990-05-15', + 'email' => 'Mia.Muster@example.com', + 'mobile' => '+49123456789', + 'gender' => 'W', + 'nationality' => 'DE', + ], + 'address' => [ + 'street' => 'Main Street 1', + 'postCode' => '12345', + 'city' => 'Berlin', + 'country' => 'DE', + ], + 'services' => [ + 'additionalServices' => [99], + ], + ], + ], + ]); + $draft->setBookingNumber(321); + $draft->setDateId(55); + $draft->setHotelId(77); + + $originalConsentCreatedAt = $consent->getCreatedAt(); + $originalConsentUpdatedAt = $consent->getUpdatedAt(); + $originalRequestCreatedAt = $request->getCreatedAt(); + $originalDraftCreatedAt = $draft->getCreatedAt(); + $originalDraftUpdatedAt = $draft->getUpdatedAt(); + + $service->anonymizeUser($user); + $service->anonymizeNewsletterConsent($consent); + $service->anonymizeNewsletterOptInRequest($request); + $service->anonymizeBookingEditDraft($draft); + + self::assertStringEndsWith('@example.test', $user->getEmail()); + self::assertSame('plain-text-password', $user->getPassword()); + self::assertSame(12345, $user->getPersonId()); + self::assertSame(67890, $user->getAddressId()); + self::assertSame(['ROLE_USER', 'ROLE_ADMIN'], $user->getRoles()); + self::assertSame(['ABC'], $user->getHotelCodes()); + self::assertSame($lastLoginAt, $user->getLastLoginAt()); + self::assertTrue($user->isProfileComplete()); + + self::assertSame($user->getEmail(), $consent->getEmail()); + self::assertSame($consent->getEmail(), $request->getEmail()); + self::assertSame($consent->getFirstName(), $request->getFirstName()); + self::assertSame($consent->getLastName(), $request->getLastName()); + self::assertSame(42, $consent->getMailjetListId()); + self::assertSame([11, 22], $request->getMailjetListIds()); + self::assertSame(str_repeat('a', 64), $request->getTokenHash()); + self::assertTrue($consent->isConfirmed()); + self::assertTrue($request->isConfirmed()); + self::assertSame($originalConsentCreatedAt, $consent->getCreatedAt()); + self::assertSame($originalConsentUpdatedAt, $consent->getUpdatedAt()); + self::assertSame($originalRequestCreatedAt, $request->getCreatedAt()); + + $formData = $draft->getFormData(); + self::assertSame('debit', $formData['paymentMethod']); + self::assertNotSame('DE02123412341234123412', $formData['bankAccount']['iban']); + self::assertSame( + $formData['participants'][0]['personalData']['firstName'].' '.$formData['participants'][0]['personalData']['lastName'], + $formData['bankAccount']['accountHolder'], + ); + self::assertNotSame('Example Bank', $formData['bankAccount']['bankName']); + self::assertSame('DE', $formData['participants'][0]['personalData']['nationality']); + self::assertSame('W', $formData['participants'][0]['personalData']['gender']); + self::assertSame('1990-05-15', $formData['participants'][0]['personalData']['dateOfBirth']); + self::assertSame($formData['participants'][0]['personalData']['email'], $formData['participants'][1]['personalData']['email']); + self::assertSame($formData['participants'][0]['personalData']['firstName'], $formData['participants'][1]['personalData']['firstName']); + self::assertSame($formData['participants'][0]['personalData']['lastName'], $formData['participants'][1]['personalData']['lastName']); + self::assertSame($formData['participants'][0]['address']['street'], $formData['participants'][1]['address']['street']); + self::assertSame($formData['participants'][0]['address']['postCode'], $formData['participants'][1]['address']['postCode']); + self::assertSame($formData['participants'][0]['address']['city'], $formData['participants'][1]['address']['city']); + self::assertSame($formData['participants'][0]['address']['country'], $formData['participants'][1]['address']['country']); + self::assertSame(7, $formData['participants'][0]['roomAssignment']['assignedRoomId']); + self::assertSame('PURCHASE-123', $formData['participants'][0]['vouchers']['purchaseVoucherCode']); + self::assertSame('PROMO-456', $formData['participants'][0]['vouchers']['promoVoucherCode']); + self::assertSame(321, $draft->getBookingNumber()); + self::assertSame(55, $draft->getDateId()); + self::assertSame(77, $draft->getHotelId()); + self::assertSame($originalDraftCreatedAt, $draft->getCreatedAt()); + self::assertSame($originalDraftUpdatedAt, $draft->getUpdatedAt()); + } +}