diff --git a/Annotations/AnonymizedEntity.php b/Annotations/AnonymizedEntity.php new file mode 100644 index 0000000..ba9ee8b --- /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 = null; + + public function __construct(array $options) + { + foreach ($options as $key => $value) { + if (!property_exists($this, $key)) { + throw new \InvalidArgumentException(sprintf('Property "%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)) { + 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..fe14127 --- /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 = null; + + /** + * 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(array $options) + { + foreach ($options as $key => $value) { + if (!property_exists($this, $key)) { + throw new \InvalidArgumentException(sprintf('Property "%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)) { + throw new \InvalidArgumentException(sprintf('Type "%s" is not allowed. Allowed values 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..726e1b8 --- /dev/null +++ b/Command/AnonymizeDataCommand.php @@ -0,0 +1,113 @@ + + */ +final class AnonymizeDataCommand extends Command +{ + /** + * {@inheritdoc} + */ + protected static $defaultName = 'ekino-data-protection:anonymize'; + + /** + * @var AnonymizedMetadataBuilder + */ + protected $anonymizedMetadataBuilder; + + /** + * @var AnonymizedMetadataValidator + */ + protected $anonymizedMetadataValidator; + + /** + * @var AnonymizedQueryBuilder + */ + 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() + { + $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!'); + + // display queries + $output->writeln('Following queries have been built and will be executed:'); + + foreach ($queries as $query) { + $output->writeln(sprintf('%s', $query)); + } + + return 0; + } + + // display & execute queries + + $output->writeln(sprintf('Anonymization ends')); + + return 0; + } +} diff --git a/Meta/AnonymizedMetadata.php b/Meta/AnonymizedMetadata.php new file mode 100644 index 0000000..c47e28e --- /dev/null +++ b/Meta/AnonymizedMetadata.php @@ -0,0 +1,80 @@ + + */ +final class AnonymizedMetadata +{ + /** + * @var ClassMetadata + */ + private $classMetadata; + + /** + * @var AnonymizedEntity + */ + private $anonymizedEntity; + + /** + * @var AnonymizedProperty[] + */ + private $anonymizedProperties = []; + + public function getClassMetadata(): ClassMetadata + { + return $this->classMetadata; + } + + public function setClassMetadata(ClassMetadata $classMetadata): self + { + $this->classMetadata = $classMetadata; + + return $this; + } + + public function getAnonymizedEntity(): AnonymizedEntity + { + return $this->anonymizedEntity; + } + + public function setAnonymizedEntity(AnonymizedEntity $anonymizedEntity): self + { + $this->anonymizedEntity = $anonymizedEntity; + + return $this; + } + + public function getAnonymizedProperties(): array + { + return $this->anonymizedProperties; + } + + /** + * @param AnonymizedProperty[] $anonymizedProperties + */ + public function setAnonymizedProperties(array $anonymizedProperties): self + { + $this->anonymizedProperties = $anonymizedProperties; + + return $this; + } +} diff --git a/Meta/AnonymizedMetadataBuilder.php b/Meta/AnonymizedMetadataBuilder.php new file mode 100644 index 0000000..cf38254 --- /dev/null +++ b/Meta/AnonymizedMetadataBuilder.php @@ -0,0 +1,118 @@ + + */ +final class AnonymizedMetadataBuilder +{ + /** + * @var EntityManager + */ + private $entityManager; + + /** + * @var Reader + */ + protected $annotationReader; + + public function __construct(EntityManager $entityManager, Reader $annotationReader) + { + $this->entityManager = $entityManager; + $this->annotationReader = $annotationReader; + } + + /** + * return AnonymizerMetadata[] + */ + public function build(): array + { + $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) { + $anonymizedMetadatas[] = $this->buildAnonymizedMetadata( + $classMetadata, $anonymizedEntity, $anonymizedProperties + ); + } + } + + return $anonymizedMetadatas; + } + + private function buildAnonymizedMetadata( + ClassMetadata $classMetadata, + AnonymizedEntity $anonymizedEntity, + array $anonymizedProperties): AnonymizedMetadata + { + return (new AnonymizedMetadata()) + ->setClassMetadata($classMetadata) + ->setAnonymizedEntity($anonymizedEntity) + ->setAnonymizedProperties($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..490f7a5 --- /dev/null +++ b/Meta/AnonymizedMetadataValidator.php @@ -0,0 +1,131 @@ + + */ +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()) { + $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..f975589 --- /dev/null +++ b/QueryBuilder/AnonymizedQueryBuilder.php @@ -0,0 +1,75 @@ + + */ +final class AnonymizedQueryBuilder +{ + /** + * return string + */ + public function buildQuery(AnonymizedMetadata $anonymizedMetadata): string + { + $query = ''; + + if ($anonymizedMetadata->getAnonymizedEntity()->isTruncateAction()) { + $query = $this->buildTruncateQuery($anonymizedMetadata); + } elseif ($anonymizedMetadata->getAnonymizedEntity()->isAnonymizeAction()) { + $query = $this->buildAnonymizeQuery($anonymizedMetadata); + } + + $exceptWhereClause = $anonymizedMetadata->getAnonymizedEntity()->getExceptWhereClause() ?? ''; + + return $exceptWhereClause ? sprintf('%s WHERE %s', $query, $exceptWhereClause) : $query; + } + + private function buildTruncateQuery(AnonymizedMetadata $anonymizedMetadata): string + { + return 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->isStatic()) { + return sprintf('%s = "%s"', $anonymizedProperty->getColumnName(), $anonymizedProperty->getValue()); + } elseif ($anonymizedProperty->isComposed()) { + $composedFieldParts = $anonymizedProperty->explodeComposedFieldValue(); + + return sprintf('%s = concat(concat("%s", %s), "%s")', $anonymizedProperty->getColumnName(), + $composedFieldParts[1], $composedFieldParts[2],$composedFieldParts[3]); + } elseif ($anonymizedProperty->isExpression()) { + return sprintf('%s = %s', $anonymizedProperty->getColumnName(), $anonymizedProperty->getValue()); + } + } +} diff --git a/README.md b/README.md index c58bfb0..1de794e 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,14 @@ 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 on your test environment for example and you care about who access to real client/user data +according to GDPR reglementation. + +@todo : add more details on usage + [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..4001ef6 --- /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('Property "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..0b3079d --- /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('Property "foo" does not exist'); + + new AnonymizedProperty([ + 'foo' => 'bar', + ]); + } + + public function testAnonymizedEntityWithInvalidType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Type "foo" is not allowed. Allowed values 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..8be7629 --- /dev/null +++ b/Tests/Entity/AnonymizedPropertiesAndTruncateAnonymizedEntityAction.php @@ -0,0 +1,47 @@ + + */ +class AnonymizedPropertiesAndTruncateAnonymizedEntityAction implements ClassMetadataProviderInterface +{ + /** + * @var string + * @AnonymizedProperty() + */ + public $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..1265335 --- /dev/null +++ b/Tests/Entity/AnonymizedPropertyNullOnNotNullableProperty.php @@ -0,0 +1,47 @@ + + */ +class AnonymizedPropertyNullOnNotNullableProperty implements ClassMetadataProviderInterface +{ + /** + * @var string + * @AnonymizedProperty() + */ + public $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..8688a4a --- /dev/null +++ b/Tests/Entity/AnonymizedPropertyOnAssociationField.php @@ -0,0 +1,47 @@ + + */ +class AnonymizedPropertyOnAssociationField implements ClassMetadataProviderInterface +{ + /** + * @var Foo + * @AnonymizedProperty(value="foo") + */ + public $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..ed2e046 --- /dev/null +++ b/Tests/Entity/AnonymizedPropertyWithoutAnonymizedEntity.php @@ -0,0 +1,45 @@ + + */ +class AnonymizedPropertyWithoutAnonymizedEntity implements ClassMetadataProviderInterface +{ + /** + * @var string + * @AnonymizedProperty() + */ + public $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..555c7c3 --- /dev/null +++ b/Tests/Entity/ClassMetadataProviderInterface.php @@ -0,0 +1,28 @@ + + */ +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..0bb7414 --- /dev/null +++ b/Tests/Entity/ClassMetadataProviderTrait.php @@ -0,0 +1,40 @@ + + */ +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..4e892b3 --- /dev/null +++ b/Tests/Entity/ComposedAnonymizedPropertyWithNotUniqueField.php @@ -0,0 +1,54 @@ + + */ +class ComposedAnonymizedPropertyWithNotUniqueField implements ClassMetadataProviderInterface +{ + /** + * @var string + * @AnonymizedProperty(type="composed", value="test-") + */ + public $bar; + + /** + * @var string + */ + public $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..9c4c3c8 --- /dev/null +++ b/Tests/Entity/ComposedAnonymizedPropertyWithUnknownField.php @@ -0,0 +1,47 @@ + + */ +class ComposedAnonymizedPropertyWithUnknownField implements ClassMetadataProviderInterface +{ + /** + * @var string + * @AnonymizedProperty(type="composed", value="bar--baz") + */ + public $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..25a9b7f --- /dev/null +++ b/Tests/Entity/ComposedAnonymizedPropertyWithoutComposedField.php @@ -0,0 +1,47 @@ + + */ +class ComposedAnonymizedPropertyWithoutComposedField implements ClassMetadataProviderInterface +{ + /** + * @var string + * @AnonymizedProperty(type="composed", value="foo") + */ + public $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..419dbf2 --- /dev/null +++ b/Tests/Entity/Foo.php @@ -0,0 +1,52 @@ + + */ +class Foo implements ClassMetadataProviderInterface +{ + /** + * @var string + * @AnonymizedProperty(value="lorem") + */ + public $bar; + + /** + * @var string + */ + public $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..1473318 --- /dev/null +++ b/Tests/Entity/UniqueFieldWithoutComposedAnonymizedProperty.php @@ -0,0 +1,49 @@ + + */ +class UniqueFieldWithoutComposedAnonymizedProperty implements ClassMetadataProviderInterface +{ + /** + * @var string + * @AnonymizedProperty(type="static", value="foo") + */ + public $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..cbb1c2f --- /dev/null +++ b/Tests/Entity/ValidAnonymizedEntity.php @@ -0,0 +1,69 @@ + + */ +class ValidAnonymizedEntity implements ClassMetadataProviderInterface +{ + /** + * @var int + */ + public $id; + + /** + * @var string + * @AnonymizedProperty(type="static", value="lorem") + */ + public $bar; + + /** + * @var string + * @AnonymizedProperty(type="composed", value="lorem-") + */ + public $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..10961b8 --- /dev/null +++ b/Tests/Entity/ValidTruncateAnonymizedEntity.php @@ -0,0 +1,45 @@ + + */ +class ValidTruncateAnonymizedEntity implements ClassMetadataProviderInterface +{ + /** + * @var string + */ + public $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..779e484 --- /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(); + } + + public function testBuild(): void + { + $classMetadata = $this->getClassMetadata(Foo::class); + $this->classMetadataFactory->expects($this->once())->method('getAllMetadata')->willReturn([$classMetadata]); + + /** @var AnonymizedMetadata[] $anonymizedMetadatas */ + $anonymizedMetadatas = $this->anonymizedMetadataBuilder->build(); + $this->assertCount(1, $anonymizedMetadatas); + $anonymizedMetadata = $anonymizedMetadatas[0]; + $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..54a8ddf --- /dev/null +++ b/Tests/Meta/AnonymizedMetadataProviderTrait.php @@ -0,0 +1,46 @@ + + */ +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); + + return $anonymizedMetadataBuilder->build()[0]; + } +} 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..3cf2d5d --- /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, + 'DELETE * FROM 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",