From 12699cf593865ef62163232251ab3c290822ac9c Mon Sep 17 00:00:00 2001 From: Benoit Maziere Date: Sat, 22 Feb 2020 13:01:53 +0100 Subject: [PATCH] Add anonymizer feature --- Annotations/AnonymizedEntity.php | 82 +++++++++++ Annotations/AnonymizedProperty.php | 136 ++++++++++++++++++ CHANGELOG.md | 1 + Command/AnonymizeDataCommand.php | 127 ++++++++++++++++ Meta/AnonymizedMetadata.php | 58 ++++++++ Meta/AnonymizedMetadataBuilder.php | 98 +++++++++++++ Meta/AnonymizedMetadataValidator.php | 135 +++++++++++++++++ QueryBuilder/AnonymizedQueryBuilder.php | 93 ++++++++++++ README.md | 45 ++++++ Resources/config/services.xml | 18 +++ Tests/Annotations/AnonymizedEntityTest.php | 58 ++++++++ Tests/Annotations/AnonymizedPropertyTest.php | 59 ++++++++ ...rtiesAndTruncateAnonymizedEntityAction.php | 47 ++++++ ...mizedPropertyNullOnNotNullableProperty.php | 47 ++++++ .../AnonymizedPropertyOnAssociationField.php | 47 ++++++ ...nymizedPropertyWithoutAnonymizedEntity.php | 45 ++++++ .../Entity/ClassMetadataProviderInterface.php | 29 ++++ Tests/Entity/ClassMetadataProviderTrait.php | 41 ++++++ ...edAnonymizedPropertyWithNotUniqueField.php | 54 +++++++ ...osedAnonymizedPropertyWithUnknownField.php | 47 ++++++ ...AnonymizedPropertyWithoutComposedField.php | 47 ++++++ Tests/Entity/Foo.php | 52 +++++++ ...FieldWithoutComposedAnonymizedProperty.php | 49 +++++++ Tests/Entity/ValidAnonymizedEntity.php | 69 +++++++++ .../Entity/ValidTruncateAnonymizedEntity.php | 45 ++++++ Tests/Meta/AnonymizedMetadataBuilderTest.php | 102 +++++++++++++ .../Meta/AnonymizedMetadataProviderTrait.php | 49 +++++++ .../Meta/AnonymizedMetadataValidatorTest.php | 108 ++++++++++++++ .../AnonymizedQueryBuilderTest.php | 67 +++++++++ composer.json | 1 + 30 files changed, 1856 insertions(+) create mode 100644 Annotations/AnonymizedEntity.php create mode 100644 Annotations/AnonymizedProperty.php create mode 100644 Command/AnonymizeDataCommand.php create mode 100644 Meta/AnonymizedMetadata.php create mode 100644 Meta/AnonymizedMetadataBuilder.php create mode 100644 Meta/AnonymizedMetadataValidator.php create mode 100644 QueryBuilder/AnonymizedQueryBuilder.php create mode 100644 Tests/Annotations/AnonymizedEntityTest.php create mode 100644 Tests/Annotations/AnonymizedPropertyTest.php create mode 100644 Tests/Entity/AnonymizedPropertiesAndTruncateAnonymizedEntityAction.php create mode 100644 Tests/Entity/AnonymizedPropertyNullOnNotNullableProperty.php create mode 100644 Tests/Entity/AnonymizedPropertyOnAssociationField.php create mode 100644 Tests/Entity/AnonymizedPropertyWithoutAnonymizedEntity.php create mode 100644 Tests/Entity/ClassMetadataProviderInterface.php create mode 100644 Tests/Entity/ClassMetadataProviderTrait.php create mode 100644 Tests/Entity/ComposedAnonymizedPropertyWithNotUniqueField.php create mode 100644 Tests/Entity/ComposedAnonymizedPropertyWithUnknownField.php create mode 100644 Tests/Entity/ComposedAnonymizedPropertyWithoutComposedField.php create mode 100644 Tests/Entity/Foo.php create mode 100644 Tests/Entity/UniqueFieldWithoutComposedAnonymizedProperty.php create mode 100644 Tests/Entity/ValidAnonymizedEntity.php create mode 100644 Tests/Entity/ValidTruncateAnonymizedEntity.php create mode 100644 Tests/Meta/AnonymizedMetadataBuilderTest.php create mode 100644 Tests/Meta/AnonymizedMetadataProviderTrait.php create mode 100644 Tests/Meta/AnonymizedMetadataValidatorTest.php create mode 100644 Tests/QueryBuilder/AnonymizedQueryBuilderTest.php diff --git a/Annotations/AnonymizedEntity.php b/Annotations/AnonymizedEntity.php new file mode 100644 index 0000000..6a78135 --- /dev/null +++ b/Annotations/AnonymizedEntity.php @@ -0,0 +1,82 @@ + + */ +final class AnonymizedEntity +{ + public const ACTION_ANONYMIZE = 'anonymize'; + public const ACTION_TRUNCATE = 'truncate'; + private const ACTION_CHOICES = [self::ACTION_ANONYMIZE, self::ACTION_TRUNCATE]; + + /** + * @var string + */ + private $action = self::ACTION_ANONYMIZE; + + /** + * Add where sql condition on which not apply anonymization. + * + * @var string|null + */ + private $exceptWhereClause; + + public function __construct(iterable $options) + { + foreach ($options as $key => $value) { + if (!property_exists($this, $key)) { + throw new \InvalidArgumentException(sprintf('Option "%s" does not exist', $key)); + } + + $this->$key = $value; + } + + $this->validateAction(); + } + + public function getAction(): string + { + return $this->action; + } + + public function getExceptWhereClause(): ?string + { + return $this->exceptWhereClause; + } + + public function isTruncateAction(): bool + { + return static::ACTION_TRUNCATE === $this->action; + } + + public function isAnonymizeAction(): bool + { + return static::ACTION_ANONYMIZE === $this->action; + } + + private function validateAction(): void + { + if (!\in_array($this->action, static::ACTION_CHOICES, true)) { + throw new \InvalidArgumentException(sprintf('Action "%s" is not allowed. Allowed actions are: %s', + $this->action, implode(', ', static::ACTION_CHOICES))); + } + } +} diff --git a/Annotations/AnonymizedProperty.php b/Annotations/AnonymizedProperty.php new file mode 100644 index 0000000..4a57e82 --- /dev/null +++ b/Annotations/AnonymizedProperty.php @@ -0,0 +1,136 @@ + + */ +final class AnonymizedProperty +{ + public const TYPE_STATIC = 'static'; + public const TYPE_COMPOSED = 'composed'; + public const TYPE_EXPRESSION = 'expression'; + private const TYPE_CHOICES = [self::TYPE_STATIC, self::TYPE_COMPOSED, self::TYPE_EXPRESSION]; + + /** + * @var mixed|null + */ + private $value; + + /** + * Can be of type static (fixed value) or composed (mix of static & existing field value). + * + * @var string + */ + private $type = self::TYPE_STATIC; + + /** + * @var string + */ + private $fieldName; + + /** + * @var string + */ + private $columnName; + + public function __construct(iterable $options) + { + foreach ($options as $key => $value) { + if (!property_exists($this, $key)) { + throw new \InvalidArgumentException(sprintf('Option "%s" does not exist', $key)); + } + + $this->$key = $value; + } + + $this->validateType(); + } + + public function getValue() + { + return $this->value; + } + + public function getType(): string + { + return $this->type; + } + + public function getFieldName(): string + { + return $this->fieldName; + } + + public function setFieldName(string $fieldName): self + { + $this->fieldName = $fieldName; + + return $this; + } + + public function getColumnName(): string + { + return $this->columnName; + } + + public function setColumnName(string $columnName): self + { + $this->columnName = $columnName; + + return $this; + } + + public function isStatic(): bool + { + return static::TYPE_STATIC === $this->type; + } + + public function isComposed(): bool + { + return static::TYPE_COMPOSED === $this->type; + } + + public function isExpression(): bool + { + return static::TYPE_EXPRESSION === $this->type; + } + + public function extractComposedFieldFromValue(): string + { + preg_match('/<(\w*)>/', $this->value, $matches); + + return $matches[1] ?? ''; + } + + public function explodeComposedFieldValue(): array + { + preg_match('/(.*)<(\w*)>(.*)/', $this->value, $matches); + + return $matches ?? []; + } + + private function validateType(): void + { + if (!\in_array($this->type, static::TYPE_CHOICES, true)) { + throw new \InvalidArgumentException(sprintf('Type "%s" is not allowed. Allowed types are: %s', + $this->type, implode(', ', static::TYPE_CHOICES))); + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 63e797a..b77eb8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ master * Drop support for PHP 7.1 * Add PHP 7.4 in CI * Upgrade PhpUnit to 8 +* Add command to anonymize database through annotation configuration v1.1.0 ------ diff --git a/Command/AnonymizeDataCommand.php b/Command/AnonymizeDataCommand.php new file mode 100644 index 0000000..47faa70 --- /dev/null +++ b/Command/AnonymizeDataCommand.php @@ -0,0 +1,127 @@ + + */ +final class AnonymizeDataCommand extends Command +{ + /** + * {@inheritdoc} + */ + protected static $defaultName = 'ekino-data-protection:anonymize'; + + protected $anonymizedMetadataBuilder; + + protected $anonymizedMetadataValidator; + + protected $anonymizedQueryBuilder; + + public function __construct( + AnonymizedMetadataBuilder $anonymizedMetadataBuilder, + AnonymizedMetadataValidator $anonymizedMetadataValidator, + AnonymizedQueryBuilder $anonymizedQueryBuilder + ) + { + parent::__construct(); + + $this->anonymizedMetadataBuilder = $anonymizedMetadataBuilder; + $this->anonymizedMetadataValidator = $anonymizedMetadataValidator; + $this->anonymizedQueryBuilder = $anonymizedQueryBuilder; + } + + /** + * {@inheritdoc} + */ + protected function configure(): void + { + $this->setDescription('Anonymize database based on entities annotations') + ->addOption('force', null, InputOption::VALUE_NONE, 'Set this parameter to execute this action') + ->setHelp('Usage: `bin/console ekino-data-protection:anonymize`') + ; + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $output->writeln(sprintf('Anonymization starts')); + + $anonymizedMetadatas = $this->anonymizedMetadataBuilder->build(); + $queries = []; + + foreach ($anonymizedMetadatas as $anonymizedMetadata) { + $this->anonymizedMetadataValidator->validate($anonymizedMetadata); + $queries[] = $this->anonymizedQueryBuilder->buildQuery($anonymizedMetadata); + } + + if (!$input->getOption('force')) { + $output->writeln('ATTENTION: This operation should not be executed in a production environment.'); + $output->writeln(''); + $output->writeln('Would annoymize your database according to your configuration.'); + $output->writeln('Please run the operation with --force to execute'); + $output->writeln('Some data will be lost/anonymized!'); + + $this->displayQueries($queries, $output); + + return 0; + } + + $question = 'Are you sure you wish to continue & anonymize your database? (y/n)'; + + if (! $this->canExecute($question, $input, $output)) { + $output->writeln('Anonymization cancelled!'); + + return 1; + } + + $this->displayQueries($queries, $output); + // @todo execute queries + $output->writeln(sprintf('Anonymization ends')); + + return 0; + } + + private function displayQueries(array $queries, OutputInterface $output): void + { + $output->writeln('Following queries have been built and will be executed:'); + + foreach ($queries as $query) { + $output->writeln(sprintf('%s', $query)); + } + } + + private function askConfirmation(string $question, InputInterface $input, OutputInterface $output): bool + { + return $this->getHelper('question')->ask($input, $output, new ConfirmationQuestion($question)); + } + + private function canExecute(string $question, InputInterface $input, OutputInterface $output ): bool + { + return ! $input->isInteractive() || $this->askConfirmation($question, $input, $output); + } +} diff --git a/Meta/AnonymizedMetadata.php b/Meta/AnonymizedMetadata.php new file mode 100644 index 0000000..91f001d --- /dev/null +++ b/Meta/AnonymizedMetadata.php @@ -0,0 +1,58 @@ + + */ +final class AnonymizedMetadata +{ + private $classMetadata; + + private $anonymizedEntity; + + /** + * @var AnonymizedProperty[] + */ + private $anonymizedProperties = []; + + public function __construct(ClassMetadata $classMetadata, AnonymizedEntity $anonymizedEntity, iterable $anonymizedProperties) + { + $this->classMetadata = $classMetadata; + $this->anonymizedEntity = $anonymizedEntity; + $this->anonymizedProperties = $anonymizedProperties; + } + + public function getClassMetadata(): ClassMetadata + { + return $this->classMetadata; + } + + public function getAnonymizedEntity(): AnonymizedEntity + { + return $this->anonymizedEntity; + } + + public function getAnonymizedProperties(): array + { + return $this->anonymizedProperties; + } +} diff --git a/Meta/AnonymizedMetadataBuilder.php b/Meta/AnonymizedMetadataBuilder.php new file mode 100644 index 0000000..6778a4b --- /dev/null +++ b/Meta/AnonymizedMetadataBuilder.php @@ -0,0 +1,98 @@ + + */ +final class AnonymizedMetadataBuilder +{ + private $entityManager; + + protected $annotationReader; + + public function __construct(EntityManagerInterface $entityManager, Reader $annotationReader) + { + $this->entityManager = $entityManager; + $this->annotationReader = $annotationReader; + } + + /** + * return \Generator + */ + public function build(): \Generator + { + $anonymizedMetadatas = []; + /** @var ClassMetadata[] $classMetadatas */ + $classMetadatas = $this->entityManager->getMetadataFactory()->getAllMetadata(); + + foreach ($classMetadatas as $classMetadata) { + $anonymizedEntity = $this->buildAnonymizedEntityAnnotations($classMetadata); + $anonymizedProperties = $this->buildAnonymizedPropertiesAnnotations($classMetadata); + + if (!$anonymizedEntity && !empty($anonymizedProperties)) { + throw AnnotationException::creationError( + sprintf('You tried to anonymize a property without specifying it at class level in %s. + You should add @AnonymizedEntity() in class phpdoc', $classMetadata->getName())); + } + + if ($anonymizedEntity) { + yield new AnonymizedMetadata($classMetadata, $anonymizedEntity, $anonymizedProperties); + } + } + } + + private function buildAnonymizedPropertiesAnnotations(ClassMetadata $classMetadata): array + { + $anonymizedProperties = []; + $properties = $classMetadata->getFieldNames(); + + foreach ($properties as $property) { + /** @var AnonymizedProperty|null $anonymizedProperty */ + $anonymizedProperty = $this->annotationReader->getPropertyAnnotation( + new \ReflectionProperty($classMetadata->getName(), $property), + AnonymizedProperty::class + ); + + if (\is_null($anonymizedProperty)) { + continue; + } + + $anonymizedProperty->setFieldName($property)->setColumnName($classMetadata->getColumnName($property)); + $anonymizedProperties[] = $anonymizedProperty; + } + + return $anonymizedProperties; + } + + private function buildAnonymizedEntityAnnotations(ClassMetadata $classMetadata): ?AnonymizedEntity + { + /** @var AnonymizedEntity|null $anonymizedEntity */ + $anonymizedEntity = $this->annotationReader->getClassAnnotation( + new \ReflectionClass($classMetadata->getName()), AnonymizedEntity::class + ); + + return $anonymizedEntity; + } +} diff --git a/Meta/AnonymizedMetadataValidator.php b/Meta/AnonymizedMetadataValidator.php new file mode 100644 index 0000000..991100c --- /dev/null +++ b/Meta/AnonymizedMetadataValidator.php @@ -0,0 +1,135 @@ + + */ +final class AnonymizedMetadataValidator +{ + public function validate(AnonymizedMetadata $anonymizedMetadata): void + { + $this->anonymizedPropertiesMustBeEmptyIfAnonymizedEntityActionIsTruncate($anonymizedMetadata); + + $anonymizedProperties = $anonymizedMetadata->getAnonymizedProperties(); + $classMetadata = $anonymizedMetadata->getClassMetadata(); + + foreach ($anonymizedProperties as $anonymizedProperty) { + $this->propertyMustBeNullableIfStaticValueIsNull($anonymizedProperty, $classMetadata); + $this->propertyMustExistsInComposedExpression($anonymizedProperty, $classMetadata); + $this->composedFieldForUniquePropertyMustBeUnique($anonymizedProperty, $classMetadata); + $this->anonymizedPropertyMustBeComposedIfFieldHasUniqueIndex($anonymizedProperty, $classMetadata); + $this->associationPropertyMustNotHaveAnonymizedProperty($anonymizedProperty, $classMetadata); + } + } + + private function anonymizedPropertiesMustBeEmptyIfAnonymizedEntityActionIsTruncate( + AnonymizedMetadata $anonymizedMetadata): void + { + $anonymizedEntity = $anonymizedMetadata->getAnonymizedEntity(); + + if ($anonymizedEntity->isTruncateAction() + && !empty($anonymizedMetadata->getAnonymizedProperties())) { + throw AnnotationException::creationError( + sprintf('If %s action is set at class level, it can\'t have property annotation in %s', + AnonymizedEntity::ACTION_TRUNCATE, $anonymizedMetadata->getClassMetadata()->getName())); + } + } + + private function anonymizedPropertyMustBeComposedIfFieldHasUniqueIndex( + AnonymizedProperty $anonymizedProperty, + ClassMetadata $classMetadata): void + { + if ($classMetadata->isUniqueField($anonymizedProperty->getFieldName()) && !$anonymizedProperty->isComposed()) { + throw AnnotationException::creationError( + sprintf('If property is unique (%s), AnonymzedProperty must be of type %s in %s', + $anonymizedProperty->getFieldName(), AnonymizedProperty::TYPE_COMPOSED, $classMetadata->getName())); + } + } + + private function propertyMustExistsInComposedExpression( + AnonymizedProperty $anonymizedProperty, + ClassMetadata $classMetadata): void + { + if (!$anonymizedProperty->isComposed()) { + return; + } + + $value = $anonymizedProperty->getValue(); + $composedField = $anonymizedProperty->extractComposedFieldFromValue(); + + if (empty($composedField)) { + throw AnnotationException::creationError( + sprintf('No composed field specified in composed expression of %s property in %s', + $anonymizedProperty->getFieldName(), $classMetadata->getName())); + } + + if (!\in_array($composedField, $classMetadata->getFieldNames())) { + throw AnnotationException::creationError( + sprintf('Property %s specified in composed expression of %s does not exists in %s', + $composedField, $anonymizedProperty->getFieldName(), $classMetadata->getName())); + } + } + + private function composedFieldForUniquePropertyMustBeUnique( + AnonymizedProperty $anonymizedProperty, + ClassMetadata $classMetadata): void + { + if (!$anonymizedProperty->isComposed()) { + return; + } + + $composedField = $anonymizedProperty->extractComposedFieldFromValue(); + + if ($classMetadata->isUniqueField($anonymizedProperty->getFieldName()) + && !$classMetadata->isUniqueField($composedField)) { + throw AnnotationException::creationError( + sprintf('If property is unique (%s), composed field %s must be unique to avoid duplicate potential value in %s', + $anonymizedProperty->getFieldName(), $composedField, $classMetadata->getName())); + } + } + + private function propertyMustBeNullableIfStaticValueIsNull( + AnonymizedProperty $anonymizedProperty, + ClassMetadata $classMetadata): void + { + if ($anonymizedProperty->isStatic() + && \is_null($anonymizedProperty->getValue()) + && !$classMetadata->isNullable($anonymizedProperty->getFieldName())) { + throw AnnotationException::creationError( + sprintf('Property %s is supposed to be anonymized to null value but is not nullable in %s', + $anonymizedProperty->getFieldName(), $classMetadata->getName())); + } + } + + private function associationPropertyMustNotHaveAnonymizedProperty( + AnonymizedProperty $anonymizedProperty, + ClassMetadata $classMetadata): void + { + if (\in_array($anonymizedProperty->getFieldName(), $classMetadata->getAssociationNames())) { + throw AnnotationException::creationError( + sprintf('Anonymization of associations (%s) is not supported in %s', + $anonymizedProperty->getFieldName(), $classMetadata->getName())); + } + } +} diff --git a/QueryBuilder/AnonymizedQueryBuilder.php b/QueryBuilder/AnonymizedQueryBuilder.php new file mode 100644 index 0000000..fe61ead --- /dev/null +++ b/QueryBuilder/AnonymizedQueryBuilder.php @@ -0,0 +1,93 @@ + + */ +final class AnonymizedQueryBuilder +{ + public function buildQuery(AnonymizedMetadata $anonymizedMetadata): string + { + $anonymizedEntity = $anonymizedMetadata->getAnonymizedEntity(); + + if ($anonymizedEntity->isTruncateAction()) { + $query = $this->buildTruncateQuery($anonymizedMetadata); + } elseif ($anonymizedEntity->isAnonymizeAction()) { + $query = $this->buildAnonymizeQuery($anonymizedMetadata); + } else { + throw new \LogicException( + sprintf('"%s" action is not expected as this point to generate a valid query.', + $anonymizedEntity->getAction() + )); + } + + return $this->suffixWithWhereClause($query, $anonymizedMetadata->getAnonymizedEntity()->getExceptWhereClause()); + } + + private function buildTruncateQuery(AnonymizedMetadata $anonymizedMetadata): string + { + $exceptWhereClause = $anonymizedMetadata->getAnonymizedEntity()->getExceptWhereClause(); + + return !$exceptWhereClause ? + sprintf('TRUNCATE TABLE %s', $anonymizedMetadata->getClassMetadata()->getTableName()) + : sprintf('DELETE FROM %s', $anonymizedMetadata->getClassMetadata()->getTableName()) + ; + } + + private function buildAnonymizeQuery(AnonymizedMetadata $anonymizedMetadata): string + { + $setters = []; + $anonymizedProperties = $anonymizedMetadata->getAnonymizedProperties(); + + foreach ($anonymizedProperties as $property => $anonymizedProperty) { + $setters[] = $this->buildPropertySetterQueryPart($anonymizedProperty); + } + + return sprintf('UPDATE %s SET %s', + $anonymizedMetadata->getClassMetadata()->getTableName(), implode(', ', array_filter($setters))); + } + + private function buildPropertySetterQueryPart(AnonymizedProperty $anonymizedProperty): string + { + if ($anonymizedProperty->isComposed()) { + $composedFieldParts = $anonymizedProperty->explodeComposedFieldValue(); + + return sprintf('%s = concat(concat("%s", %s), "%s")', $anonymizedProperty->getColumnName(), + $composedFieldParts[1], $composedFieldParts[2],$composedFieldParts[3]); + } + + if ($anonymizedProperty->isStatic() || $anonymizedProperty->isExpression()) { + $propertyValue = $anonymizedProperty->isStatic() ? + sprintf('"%s"', $anonymizedProperty->getValue()) : $anonymizedProperty->getValue(); + + return sprintf('%s = %s', $anonymizedProperty->getColumnName(), $propertyValue); + } + + throw new \LogicException( + sprintf('"%s" type is not expected as this point to generate a valid query.',$anonymizedProperty->getType()) + ); + } + + private function suffixWithWhereClause(string $baseQuery, ?string $exceptWhereClause): string + { + return $exceptWhereClause ? sprintf('%s WHERE %s', $baseQuery, $exceptWhereClause) : $baseQuery; + } +} diff --git a/README.md b/README.md index c58bfb0..3640497 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,51 @@ To encrypt a text, run the following command: `bin/console ekino-data-protection:encrypt myText`, optionally with `--secret mySecret` and/or `--method myCipher` +## Anonymize your database + +This bundle provides an easy way to anonymize your database. This feature can be useful when you get down some +production database to your staged environments for example and you care about who access to real client/user data +according to GDPR reglementation. + +Efficiency is also very important when you need to anonymize big volumetry. This bundle aims to generate at most one +query per table. + +### Annotation configuration + +This bundle provides 2 annotations: + +`@AnonyizedEntity()` to specify which entity should be considered to be anonymized + +- action option: anonymize(default)|truncate +- exceptWhereClause option: to keep some data unchanged, will be added as WHERE clause at the end of generated query + + +`@AnonyizedProperty()` to specify which entity field should be considered to be anonymized + +- type option: static(default)|composed|expression + + - static type: same value will be applied everywhere + - composed type: a string composed of an another `` (ex : test-@test.com) + - expression type: a valid expression where another field can be used or not (ex : CONCAT(FLOOR(1 + (RAND() * 1000)), id)) + +- value option: string representing static|composed|expression format + +### Process + +The command for anonymizing will : +- gather anonymizing configuration by reading annotation and build AnonymizedMetadata +- validate AnonymizedMetadata (see `Ekino\DataProtectionBundle\Meta\AnonymizedMetadataValidator`) +- build queries +- execute queries + +### Usage + +To anonymize your database, run the following command: + +`bin/console ekino-data-protection:anonymize` will display the queries that have been generated +`bin/console ekino-data-protection:anonymize --force` will display & execute the queries that have been generated + + [1]: https://php.net/manual/en/function.openssl-get-cipher-methods.php [2]: https://github.com/Seldaek/monolog [3]: https://github.com/sonata-project/SonataAdminBundle diff --git a/Resources/config/services.xml b/Resources/config/services.xml index 1d6f5d2..1e2a371 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -31,6 +31,24 @@ + + + + + + + + + + + + + + + + + + diff --git a/Tests/Annotations/AnonymizedEntityTest.php b/Tests/Annotations/AnonymizedEntityTest.php new file mode 100644 index 0000000..975a321 --- /dev/null +++ b/Tests/Annotations/AnonymizedEntityTest.php @@ -0,0 +1,58 @@ + + */ +class AnonymizedEntityTest extends TestCase +{ + public function testAnonymizedEntityWithInvalidProperty(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Option "foo" does not exist'); + + new AnonymizedEntity([ + 'foo' => 'bar', + ]); + } + + public function testAnonymizedEntityWithInvalidAction(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Action "foo" is not allowed. Allowed actions are: anonymize, truncate'); + + new AnonymizedEntity([ + 'action' => 'foo', + ]); + } + + public function testWithValidConfiguration(): void + { + $anonymizedEntity = new AnonymizedEntity([ + 'action' => AnonymizedEntity::ACTION_TRUNCATE, + 'exceptWhereClause' => 'roles NOT LIKE %foo%', + ]); + + $this->assertTrue($anonymizedEntity->isTruncateAction()); + $this->assertFalse($anonymizedEntity->isAnonymizeAction()); + $this->assertSame($anonymizedEntity->getExceptWhereClause(), 'roles NOT LIKE %foo%'); + $this->assertSame($anonymizedEntity->getAction(), AnonymizedEntity::ACTION_TRUNCATE); + } +} diff --git a/Tests/Annotations/AnonymizedPropertyTest.php b/Tests/Annotations/AnonymizedPropertyTest.php new file mode 100644 index 0000000..f332138 --- /dev/null +++ b/Tests/Annotations/AnonymizedPropertyTest.php @@ -0,0 +1,59 @@ + + */ +class AnonymizedPropertyTest extends TestCase +{ + public function testAnonymizedEntityWithInvalidProperty(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Option "foo" does not exist'); + + new AnonymizedProperty([ + 'foo' => 'bar', + ]); + } + + public function testAnonymizedEntityWithInvalidType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Type "foo" is not allowed. Allowed types are: static, composed, expression'); + + new AnonymizedProperty([ + 'type' => 'foo', + ]); + } + + public function testWithValidConfiguration(): void + { + $anonymizedProperty = new AnonymizedProperty([ + 'type' => AnonymizedProperty::TYPE_COMPOSED, + 'value' => 'test-', + ]); + + $this->assertTrue($anonymizedProperty->isComposed()); + $this->assertFalse($anonymizedProperty->isStatic()); + $this->assertFalse($anonymizedProperty->isExpression()); + $this->assertSame('id', $anonymizedProperty->extractComposedFieldFromValue()); + $this->assertSame(['test-', 'test-', 'id', ''], $anonymizedProperty->explodeComposedFieldValue()); + } +} diff --git a/Tests/Entity/AnonymizedPropertiesAndTruncateAnonymizedEntityAction.php b/Tests/Entity/AnonymizedPropertiesAndTruncateAnonymizedEntityAction.php new file mode 100644 index 0000000..c360c15 --- /dev/null +++ b/Tests/Entity/AnonymizedPropertiesAndTruncateAnonymizedEntityAction.php @@ -0,0 +1,47 @@ + + */ +class AnonymizedPropertiesAndTruncateAnonymizedEntityAction implements ClassMetadataProviderInterface +{ + /** + * @var string + * @AnonymizedProperty() + */ + private $bar; + + public static function getFieldMappings(): array + { + return ['bar' => ['fieldName' => 'bar']]; + } + + public static function getFieldNames(): array + { + return ['bar' => 'bar']; + } + + public static function getAssociationMappings(): array + { + return []; + } +} diff --git a/Tests/Entity/AnonymizedPropertyNullOnNotNullableProperty.php b/Tests/Entity/AnonymizedPropertyNullOnNotNullableProperty.php new file mode 100644 index 0000000..31f6590 --- /dev/null +++ b/Tests/Entity/AnonymizedPropertyNullOnNotNullableProperty.php @@ -0,0 +1,47 @@ + + */ +class AnonymizedPropertyNullOnNotNullableProperty implements ClassMetadataProviderInterface +{ + /** + * @var string + * @AnonymizedProperty() + */ + private $bar; + + public static function getFieldMappings(): array + { + return ['bar' => ['fieldName' => 'bar', 'nullable' => false]]; + } + + public static function getFieldNames(): array + { + return ['bar' => 'bar']; + } + + public static function getAssociationMappings(): array + { + return ['bar' => 'bar']; + } +} diff --git a/Tests/Entity/AnonymizedPropertyOnAssociationField.php b/Tests/Entity/AnonymizedPropertyOnAssociationField.php new file mode 100644 index 0000000..fbc0cac --- /dev/null +++ b/Tests/Entity/AnonymizedPropertyOnAssociationField.php @@ -0,0 +1,47 @@ + + */ +class AnonymizedPropertyOnAssociationField implements ClassMetadataProviderInterface +{ + /** + * @var Foo + * @AnonymizedProperty(value="foo") + */ + private $bar; + + public static function getFieldMappings(): array + { + return ['bar' => ['fieldName' => 'bar']]; + } + + public static function getFieldNames(): array + { + return ['bar' => 'bar']; + } + + public static function getAssociationMappings(): array + { + return ['bar' => 'bar']; + } +} diff --git a/Tests/Entity/AnonymizedPropertyWithoutAnonymizedEntity.php b/Tests/Entity/AnonymizedPropertyWithoutAnonymizedEntity.php new file mode 100644 index 0000000..a6b3e09 --- /dev/null +++ b/Tests/Entity/AnonymizedPropertyWithoutAnonymizedEntity.php @@ -0,0 +1,45 @@ + + */ +class AnonymizedPropertyWithoutAnonymizedEntity implements ClassMetadataProviderInterface +{ + /** + * @var string + * @AnonymizedProperty() + */ + private $bar; + + public static function getFieldMappings(): array + { + return ['bar' => ['fieldName' => 'bar']]; + } + + public static function getFieldNames(): array + { + return ['bar' => 'bar']; + } + + public static function getAssociationMappings(): array + { + return []; + } +} diff --git a/Tests/Entity/ClassMetadataProviderInterface.php b/Tests/Entity/ClassMetadataProviderInterface.php new file mode 100644 index 0000000..ca4c3a1 --- /dev/null +++ b/Tests/Entity/ClassMetadataProviderInterface.php @@ -0,0 +1,29 @@ + + */ +interface ClassMetadataProviderInterface +{ + public static function getFieldMappings(): array; + + public static function getFieldNames(): array; + + public static function getAssociationMappings(): array; +} diff --git a/Tests/Entity/ClassMetadataProviderTrait.php b/Tests/Entity/ClassMetadataProviderTrait.php new file mode 100644 index 0000000..b408cb5 --- /dev/null +++ b/Tests/Entity/ClassMetadataProviderTrait.php @@ -0,0 +1,41 @@ + + */ +trait ClassMetadataProviderTrait +{ + private function getClassMetadata(string $className): ClassMetadata + { + if (!is_a($className, ClassMetadataProviderInterface::class, true)) { + throw new \InvalidArgumentException(sprintf('Class %s should be an instance of %s', $className, + ClassMetadataProviderInterface::class)); + } + + $classMetadata = new ClassMetadata($className); + $classMetadata->fieldMappings = $className::getFieldMappings(); + $classMetadata->fieldNames = $className::getFieldNames(); + $classMetadata->associationMappings = $className::getAssociationMappings(); + $classMetadata->table = ['name' => strtolower((new \ReflectionClass($className))->getShortName())]; + + return $classMetadata; + } +} diff --git a/Tests/Entity/ComposedAnonymizedPropertyWithNotUniqueField.php b/Tests/Entity/ComposedAnonymizedPropertyWithNotUniqueField.php new file mode 100644 index 0000000..44ed38a --- /dev/null +++ b/Tests/Entity/ComposedAnonymizedPropertyWithNotUniqueField.php @@ -0,0 +1,54 @@ + + */ +class ComposedAnonymizedPropertyWithNotUniqueField implements ClassMetadataProviderInterface +{ + /** + * @var string + * @AnonymizedProperty(type="composed", value="test-") + */ + private $bar; + + /** + * @var string + */ + private $foo; + + public static function getFieldMappings(): array + { + return [ + 'bar' => ['fieldName' => 'bar', 'unique' => true], + 'foo' => ['fieldName' => 'foo', 'unique' => false]]; + } + + public static function getFieldNames(): array + { + return ['bar' => 'bar', 'foo' => 'foo']; + } + + public static function getAssociationMappings(): array + { + return []; + } +} diff --git a/Tests/Entity/ComposedAnonymizedPropertyWithUnknownField.php b/Tests/Entity/ComposedAnonymizedPropertyWithUnknownField.php new file mode 100644 index 0000000..ab37cda --- /dev/null +++ b/Tests/Entity/ComposedAnonymizedPropertyWithUnknownField.php @@ -0,0 +1,47 @@ + + */ +class ComposedAnonymizedPropertyWithUnknownField implements ClassMetadataProviderInterface +{ + /** + * @var string + * @AnonymizedProperty(type="composed", value="bar--baz") + */ + private $bar; + + public static function getFieldMappings(): array + { + return ['bar' => ['fieldName' => 'bar']]; + } + + public static function getFieldNames(): array + { + return ['bar' => 'bar']; + } + + public static function getAssociationMappings(): array + { + return []; + } +} diff --git a/Tests/Entity/ComposedAnonymizedPropertyWithoutComposedField.php b/Tests/Entity/ComposedAnonymizedPropertyWithoutComposedField.php new file mode 100644 index 0000000..0790365 --- /dev/null +++ b/Tests/Entity/ComposedAnonymizedPropertyWithoutComposedField.php @@ -0,0 +1,47 @@ + + */ +class ComposedAnonymizedPropertyWithoutComposedField implements ClassMetadataProviderInterface +{ + /** + * @var string + * @AnonymizedProperty(type="composed", value="foo") + */ + private $bar; + + public static function getFieldMappings(): array + { + return ['bar' => ['fieldName' => 'bar']]; + } + + public static function getFieldNames(): array + { + return ['bar' => 'bar']; + } + + public static function getAssociationMappings(): array + { + return []; + } +} diff --git a/Tests/Entity/Foo.php b/Tests/Entity/Foo.php new file mode 100644 index 0000000..e2e560c --- /dev/null +++ b/Tests/Entity/Foo.php @@ -0,0 +1,52 @@ + + */ +class Foo implements ClassMetadataProviderInterface +{ + /** + * @var string + * @AnonymizedProperty(value="lorem") + */ + private $bar; + + /** + * @var string + */ + private $baz; + + public static function getFieldMappings(): array + { + return ['bar' => ['fieldName' => 'bar'], 'baz' => ['fieldName' => 'baz']]; + } + + public static function getFieldNames(): array + { + return ['bar' => 'bar', 'baz' => 'baz']; + } + + public static function getAssociationMappings(): array + { + return []; + } +} diff --git a/Tests/Entity/UniqueFieldWithoutComposedAnonymizedProperty.php b/Tests/Entity/UniqueFieldWithoutComposedAnonymizedProperty.php new file mode 100644 index 0000000..4bce3db --- /dev/null +++ b/Tests/Entity/UniqueFieldWithoutComposedAnonymizedProperty.php @@ -0,0 +1,49 @@ + + */ +class UniqueFieldWithoutComposedAnonymizedProperty implements ClassMetadataProviderInterface +{ + /** + * @var string + * @AnonymizedProperty(type="static", value="foo") + */ + private $bar; + + public static function getFieldMappings(): array + { + return [ + 'bar' => ['fieldName' => 'bar', 'unique' => true] + ]; + } + + public static function getFieldNames(): array + { + return ['bar' => 'bar']; + } + + public static function getAssociationMappings(): array + { + return []; + } +} diff --git a/Tests/Entity/ValidAnonymizedEntity.php b/Tests/Entity/ValidAnonymizedEntity.php new file mode 100644 index 0000000..4e5acfc --- /dev/null +++ b/Tests/Entity/ValidAnonymizedEntity.php @@ -0,0 +1,69 @@ + + */ +class ValidAnonymizedEntity implements ClassMetadataProviderInterface +{ + /** + * @var int + */ + private $id; + + /** + * @var string + * @AnonymizedProperty(type="static", value="lorem") + */ + private $bar; + + /** + * @var string + * @AnonymizedProperty(type="composed", value="lorem-") + */ + private $baz; + + /** + * @var string + * @AnonymizedProperty(type="expression", value="CONCAT(FLOOR(1 + (RAND() * 1000)), id)") + */ + public $foo; + + public static function getFieldMappings(): array + { + return [ + 'id' => ['fieldName' => 'id', 'unique' => true], + 'bar' => ['fieldName' => 'bar'], + 'baz' => ['fieldName' => 'baz', 'unique' => true], + 'foo' => ['fieldName' => 'foo'], + ]; + } + + public static function getFieldNames(): array + { + return ['id' => 'id', 'bar' => 'bar', 'baz' => 'baz', 'foo' => 'foo']; + } + + public static function getAssociationMappings(): array + { + return []; + } +} diff --git a/Tests/Entity/ValidTruncateAnonymizedEntity.php b/Tests/Entity/ValidTruncateAnonymizedEntity.php new file mode 100644 index 0000000..0c05b7a --- /dev/null +++ b/Tests/Entity/ValidTruncateAnonymizedEntity.php @@ -0,0 +1,45 @@ + + */ +class ValidTruncateAnonymizedEntity implements ClassMetadataProviderInterface +{ + /** + * @var string + */ + private $bar; + + public static function getFieldMappings(): array + { + return ['bar' => ['fieldName' => 'bar']]; + } + + public static function getFieldNames(): array + { + return ['bar' => 'bar']; + } + + public static function getAssociationMappings(): array + { + return []; + } +} diff --git a/Tests/Meta/AnonymizedMetadataBuilderTest.php b/Tests/Meta/AnonymizedMetadataBuilderTest.php new file mode 100644 index 0000000..5dad3ca --- /dev/null +++ b/Tests/Meta/AnonymizedMetadataBuilderTest.php @@ -0,0 +1,102 @@ + + */ +class AnonymizedMetadataBuilderTest extends TestCase +{ + use ClassMetadataProviderTrait; + + /** + * @var AnonymizedMetadataBuilder + */ + private $anonymizedMetadataBuilder; + + /** + * @var EntityManager|MockObject + */ + private $entityManager; + + /** + * @var AnnotationReader + */ + private $annotationReader; + + /** + * @var ClassMetadataFactory|MockObject + */ + private $classMetadataFactory; + + protected function setUp(): void + { + $this->entityManager = $this->createMock(EntityManager::class); + $this->annotationReader = new AnnotationReader(); + $this->classMetadataFactory = $this->createMock(ClassMetadataFactory::class); + $this->entityManager->expects($this->once())->method('getMetadataFactory') + ->willReturn($this->classMetadataFactory); + $this->anonymizedMetadataBuilder = new AnonymizedMetadataBuilder($this->entityManager, $this->annotationReader); + } + + public function testBuildWithoutAnonymizedEntity(): void + { + $this->expectException(AnnotationException::class); + $this->expectExceptionMessage( + '[Creation Error] You tried to anonymize a property without specifying it at class level'); + + $classMetadata = $this->getClassMetadata(AnonymizedPropertyWithoutAnonymizedEntity::class); + $this->classMetadataFactory->expects($this->once())->method('getAllMetadata')->willReturn([$classMetadata]); + + $this->anonymizedMetadataBuilder->build()->current(); + } + + public function testBuild(): void + { + $classMetadata = $this->getClassMetadata(Foo::class); + $this->classMetadataFactory->expects($this->once())->method('getAllMetadata')->willReturn([$classMetadata]); + + /** @var \Generator $anonymizedMetadatas */ + $anonymizedMetadatas = $this->anonymizedMetadataBuilder->build(); + foreach ($anonymizedMetadatas as $anonymizedMetadata) { + $this->assertNotNull($anonymizedMetadata->getAnonymizedEntity()); + $this->assertInstanceOf(ClassMetadata::class, $anonymizedMetadata->getClassMetadata()); + + /** @var AnonymizedProperty[] $anonymizedProperties */ + $anonymizedProperties = $anonymizedMetadata->getAnonymizedProperties(); + $this->assertCount(1, $anonymizedProperties); + $anonymizedProperty = $anonymizedProperties[0]; + $this->assertSame('lorem', $anonymizedProperty->getValue()); + $this->assertSame(AnonymizedProperty::TYPE_STATIC, $anonymizedProperty->getType()); + $this->assertSame('bar', $anonymizedProperty->getColumnName()); + $this->assertSame('bar', $anonymizedProperty->getFieldName()); + } + } +} diff --git a/Tests/Meta/AnonymizedMetadataProviderTrait.php b/Tests/Meta/AnonymizedMetadataProviderTrait.php new file mode 100644 index 0000000..deda31b --- /dev/null +++ b/Tests/Meta/AnonymizedMetadataProviderTrait.php @@ -0,0 +1,49 @@ + + */ +trait AnonymizedMetadataProviderTrait +{ + use ClassMetadataProviderTrait; + + private function getAnonymizedMetadata(string $className): AnonymizedMetadata + { + $classMetadata = $this->getClassMetadata($className); + $entityManager = $this->createMock(EntityManager::class); + $annotationReader = new AnnotationReader(); + $classMetadataFactory = $this->createMock(ClassMetadataFactory::class); + $classMetadataFactory->expects($this->once())->method('getAllMetadata') + ->willReturn([$classMetadata]); + $entityManager->expects($this->once())->method('getMetadataFactory') + ->willReturn($classMetadataFactory); + $anonymizedMetadataBuilder = new AnonymizedMetadataBuilder($entityManager, $annotationReader); + + $anonymizedMetadatas = $anonymizedMetadataBuilder->build(); + + return $anonymizedMetadatas->current(); + } +} diff --git a/Tests/Meta/AnonymizedMetadataValidatorTest.php b/Tests/Meta/AnonymizedMetadataValidatorTest.php new file mode 100644 index 0000000..7aebcad --- /dev/null +++ b/Tests/Meta/AnonymizedMetadataValidatorTest.php @@ -0,0 +1,108 @@ + + */ +class AnonymizedMetadataValidatorTest extends TestCase +{ + use AnonymizedMetadataProviderTrait; + + /** + * @var AnonymizedMetadataValidator + */ + private $anonymizedMetadataValidator; + + protected function setUp(): void + { + $this->anonymizedMetadataValidator = new AnonymizedMetadataValidator(); + } + + /** + * @dataProvider getInvalidConfigirations + */ + public function testWithInvalidConfiguration(string $exceptionClass, string $exceptionMessage, string $entityName): void + { + $this->expectException($exceptionClass); + $this->expectExceptionMessage($exceptionMessage); + + $this->anonymizedMetadataValidator->validate($this->getAnonymizedMetadata($entityName)); + } + + public function getInvalidConfigirations(): \Generator + { + yield 'testValidateWithAnonymizedPropertiesAndMissingAnonymizedEntity' => [ + AnnotationException::class, + '[Creation Error] You tried to anonymize a property without specifying it at class level', + AnonymizedPropertyWithoutAnonymizedEntity::class + ]; + yield 'testValidateWithAnonymizedPropertiesAndTruncateAnonymizedEntityAction' => [ + AnnotationException::class, + sprintf('[Creation Error] If truncate action is set at class level, it can\'t have property annotation in %s', + AnonymizedPropertiesAndTruncateAnonymizedEntityAction::class), + AnonymizedPropertiesAndTruncateAnonymizedEntityAction::class + ]; + yield 'testValidateWithAnonymizedPropertyOnAssociationField' => [ + AnnotationException::class, + sprintf('[Creation Error] Anonymization of associations (bar) is not supported in %s', + AnonymizedPropertyOnAssociationField::class), + AnonymizedPropertyOnAssociationField::class + ]; + yield 'testValidatePropertyIsNullableOnNullValue' => [ + AnnotationException::class, + sprintf('[Creation Error] Property bar is supposed to be anonymized to null value but is not nullable in %s', + AnonymizedPropertyNullOnNotNullableProperty::class), + AnonymizedPropertyNullOnNotNullableProperty::class + ]; + yield 'testValidateComposedAnonymizedPropertyWithoutComposedField' => [ + AnnotationException::class, + sprintf('[Creation Error] No composed field specified in composed expression of bar property in %s', + ComposedAnonymizedPropertyWithoutComposedField::class), + ComposedAnonymizedPropertyWithoutComposedField::class + ]; + yield 'testValidateComposedAnonymizedPropertyWithUnknownField' => [ + AnnotationException::class, + sprintf('[Creation Error] Property foo specified in composed expression of bar does not exists in %s', + ComposedAnonymizedPropertyWithUnknownField::class), + ComposedAnonymizedPropertyWithUnknownField::class + ]; + yield 'testValidateComposedAnonymizedPropertyWithNotUniqueField' => [ + AnnotationException::class, + sprintf('[Creation Error] If property is unique (bar), composed field foo must be unique to avoid duplicate potential value in %s', + ComposedAnonymizedPropertyWithNotUniqueField::class), + ComposedAnonymizedPropertyWithNotUniqueField::class + ]; + yield 'testValidateUniqueFieldWithoutComposedAnonymizedProperty' => [ + AnnotationException::class, + sprintf('[Creation Error] If property is unique (bar), AnonymzedProperty must be of type composed in %s', + UniqueFieldWithoutComposedAnonymizedProperty::class), + UniqueFieldWithoutComposedAnonymizedProperty::class + ]; + } +} diff --git a/Tests/QueryBuilder/AnonymizedQueryBuilderTest.php b/Tests/QueryBuilder/AnonymizedQueryBuilderTest.php new file mode 100644 index 0000000..5a9dd23 --- /dev/null +++ b/Tests/QueryBuilder/AnonymizedQueryBuilderTest.php @@ -0,0 +1,67 @@ + + */ +class AnonymizedQueryBuilderTest extends TestCase +{ + use AnonymizedMetadataProviderTrait; + + /** + * @var AnonymizedQueryBuilder + */ + private $anonymizedQueryBuilder; + + protected function setUp(): void + { + $this->anonymizedQueryBuilder = new AnonymizedQueryBuilder(); + } + + /** + * @dataProvider getAnonymizedMetadatas + */ + public function testBuildQuery(string $entityName, string $expectedQuery): void + { + $this->assertSame($expectedQuery, + $this->anonymizedQueryBuilder->buildQuery($this->getAnonymizedMetadata($entityName)) + ); + } + + public function getAnonymizedMetadatas(): \Generator + { + yield 'Foo' => [ + Foo::class, + 'UPDATE foo SET bar = "lorem"' + ]; + yield 'ValidTruncateAnonymizedEntity' => [ + ValidTruncateAnonymizedEntity::class, + 'TRUNCATE TABLE validtruncateanonymizedentity' + ]; + yield 'ValidAnonymizedEntity' => [ + ValidAnonymizedEntity::class, + 'UPDATE validanonymizedentity SET bar = "lorem", baz = concat(concat("lorem-", id), ""), foo = CONCAT(FLOOR(1 + (RAND() * 1000)), id) WHERE foo NOT LIKE \'%bar%\'' + ]; + } +} diff --git a/composer.json b/composer.json index c2991a1..7e3c23a 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "php": "^7.2", "ext-json": "*", "ext-openssl": "*", + "doctrine/orm": "^2.6", "monolog/monolog": "~1.24|~2.0", "symfony/config": "~3.3|~4.4", "symfony/console": "~3.3|~4.4",