feat: cli command to anonymize local database

This commit is contained in:
Björn Fromme
2026-06-17 10:32:02 +02:00
parent 66d0f511df
commit 80569e0977
7 changed files with 786 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Service\DatabaseAnonymizer;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:db:anonymize',
description: 'Anonymize personal data in the local database',
)]
final class DbAnonymizeCommand extends Command
{
public function __construct(
private readonly DatabaseAnonymizer $databaseAnonymizer,
private readonly LoggerInterface $logger,
private readonly string $environment,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if ('prod' === $this->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;
}
}
+14
View File
@@ -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<string, mixed> $formData
*/
public function replaceFormData(array $formData): static
{
$this->formData = $formData;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
+15
View File
@@ -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;
+5
View File
@@ -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;
+445
View File
@@ -0,0 +1,445 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\BookingEditDraft;
use App\Entity\NewsletterConsent;
use App\Entity\NewsletterOptInRequest;
use App\Entity\User;
use Faker\Factory;
use Faker\Generator;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Anonymizes local database records while keeping identifiers and dates intact.
*
* The service reuses the same synthetic identity for repeated source identities
* during one run so related rows stay linked after anonymization.
*/
class DatabaseAnonymizer
{
private const string SYNTHETIC_EMAIL_DOMAIN = 'example.test';
private const string SYNTHETIC_COUNTRY = 'DE';
private const string SYNTHETIC_IBAN = 'DE89370400440532013000';
private const int DEFAULT_BATCH_SIZE = 100;
/**
* @var array<string, array<string, string>>
*/
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<string, mixed> $formData
*
* @return array<string, mixed>
*/
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<string, mixed> $participantData
*
* @return array<string, mixed>
*/
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<string, mixed> $personalData
* @param array<string, string> $identity
*
* @return array<string, mixed>
*/
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<string, mixed> $addressData
* @param array<string, string> $identity
*
* @return array<string, mixed>
*/
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<string, mixed> $bankAccountData
* @param array<string, string>|null $identity
*
* @return array<string, mixed>
*/
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<string, string>
*/
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<string, string>
*/
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;
}
}