From b022b117139e7c9049e8fd3e731b89520b002979 Mon Sep 17 00:00:00 2001 From: Damien Harper Date: Wed, 15 Apr 2020 01:20:24 +0200 Subject: [PATCH] Add initial set of files --- .gitignore | 7 + .php_cs | 71 +++ LICENSE | 21 + README.md | 1 + composer.json | 59 +++ phpstan.neon | 28 ++ phpunit.xml.dist | 35 ++ src/Auditor.php | 67 +++ src/Configuration.php | 77 +++ src/Event/AuditEvent.php | 44 ++ src/Event/LifecycleEvent.php | 7 + src/EventSubscriber/AuditEventSubscriber.php | 34 ++ src/Model/Entry.php | 183 +++++++ src/Model/Transaction.php | 84 ++++ src/Provider/AbstractProvider.php | 25 + .../Doctrine/Annotation/AnnotationLoader.php | 87 ++++ .../Doctrine/Annotation/Auditable.php | 20 + src/Provider/Doctrine/Annotation/Ignore.php | 14 + src/Provider/Doctrine/Annotation/Security.php | 24 + .../Command/CleanAuditLogsCommand.php | 166 +++++++ .../Doctrine/Command/UpdateSchemaCommand.php | 148 ++++++ src/Provider/Doctrine/Configuration.php | 188 +++++++ src/Provider/Doctrine/DoctrineProvider.php | 130 +++++ .../Doctrine/Event/AuditSubscriber.php | 68 +++ .../Doctrine/Event/CreateSchemaListener.php | 81 +++ .../Doctrine/Event/DoctrineSubscriber.php | 77 +++ .../Exception/InvalidArgumentException.php | 9 + .../Doctrine/Exception/UpdateException.php | 9 + .../Doctrine/Helper/DoctrineHelper.php | 52 ++ src/Provider/Doctrine/Helper/SchemaHelper.php | 141 ++++++ src/Provider/Doctrine/Logger/Logger.php | 36 ++ src/Provider/Doctrine/Logger/LoggerChain.php | 49 ++ src/Provider/Doctrine/Reader/Paginator.php | 69 +++ src/Provider/Doctrine/Reader/Reader.php | 469 ++++++++++++++++++ .../Doctrine/Transaction/AuditTrait.php | 215 ++++++++ .../Transaction/TransactionHydrator.php | 136 +++++ .../Transaction/TransactionProcessor.php | 303 +++++++++++ .../Doctrine/Updater/UpdateManager.php | 217 ++++++++ src/Provider/ProviderInterface.php | 13 + .../TransactionHydratorInterface.php | 7 + .../TransactionProcessorInterface.php | 7 + tests/AuditorTest.php | 52 ++ tests/ConfigurationTest.php | 54 ++ .../AuditEventSubscriberTest.php | 27 + tests/Fixtures/DummyProvider.php | 14 + tests/Provider/Doctrine/ConfigurationTest.php | 115 +++++ .../Doctrine/DoctrineProviderTest.php | 316 ++++++++++++ .../Entity/Annotation/AuditedEntity.php | 33 ++ .../Entity/Annotation/UnauditedEntity.php | 34 ++ .../Fixtures/Entity/Inheritance/Animal.php | 43 ++ .../Fixtures/Entity/Inheritance/Bike.php | 12 + .../Fixtures/Entity/Inheritance/Car.php | 12 + .../Fixtures/Entity/Inheritance/Cat.php | 13 + .../Fixtures/Entity/Inheritance/Dog.php | 13 + .../Fixtures/Entity/Inheritance/Vehicle.php | 60 +++ .../Fixtures/Entity/Standard/Author.php | 146 ++++++ .../Fixtures/Entity/Standard/Comment.php | 195 ++++++++ .../Fixtures/Entity/Standard/DummyEntity.php | 154 ++++++ .../Fixtures/Entity/Standard/Post.php | 318 ++++++++++++ .../Doctrine/Fixtures/Entity/Standard/Tag.php | 121 +++++ .../Doctrine/Fixtures/Issue37/Locale.php | 68 +++ .../Doctrine/Fixtures/Issue37/User.php | 123 +++++ .../Doctrine/Fixtures/Issue40/CoreCase.php | 29 ++ .../Doctrine/Fixtures/Issue40/DieselCase.php | 45 ++ .../Doctrine/Traits/ConnectionTrait.php | 92 ++++ .../Doctrine/Traits/DoctrineProviderTrait.php | 28 ++ .../Traits/EntityManagerInterfaceTrait.php | 62 +++ .../Traits/ProviderConfigurationTrait.php | 21 + tests/Traits/AuditorConfigurationTrait.php | 18 + tests/Traits/AuditorTrait.php | 27 + 70 files changed, 5723 insertions(+) create mode 100644 .gitignore create mode 100644 .php_cs create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 phpstan.neon create mode 100644 phpunit.xml.dist create mode 100644 src/Auditor.php create mode 100644 src/Configuration.php create mode 100644 src/Event/AuditEvent.php create mode 100644 src/Event/LifecycleEvent.php create mode 100644 src/EventSubscriber/AuditEventSubscriber.php create mode 100644 src/Model/Entry.php create mode 100644 src/Model/Transaction.php create mode 100644 src/Provider/AbstractProvider.php create mode 100644 src/Provider/Doctrine/Annotation/AnnotationLoader.php create mode 100644 src/Provider/Doctrine/Annotation/Auditable.php create mode 100644 src/Provider/Doctrine/Annotation/Ignore.php create mode 100644 src/Provider/Doctrine/Annotation/Security.php create mode 100644 src/Provider/Doctrine/Command/CleanAuditLogsCommand.php create mode 100644 src/Provider/Doctrine/Command/UpdateSchemaCommand.php create mode 100644 src/Provider/Doctrine/Configuration.php create mode 100644 src/Provider/Doctrine/DoctrineProvider.php create mode 100644 src/Provider/Doctrine/Event/AuditSubscriber.php create mode 100644 src/Provider/Doctrine/Event/CreateSchemaListener.php create mode 100644 src/Provider/Doctrine/Event/DoctrineSubscriber.php create mode 100644 src/Provider/Doctrine/Exception/InvalidArgumentException.php create mode 100644 src/Provider/Doctrine/Exception/UpdateException.php create mode 100644 src/Provider/Doctrine/Helper/DoctrineHelper.php create mode 100644 src/Provider/Doctrine/Helper/SchemaHelper.php create mode 100644 src/Provider/Doctrine/Logger/Logger.php create mode 100644 src/Provider/Doctrine/Logger/LoggerChain.php create mode 100644 src/Provider/Doctrine/Reader/Paginator.php create mode 100644 src/Provider/Doctrine/Reader/Reader.php create mode 100644 src/Provider/Doctrine/Transaction/AuditTrait.php create mode 100644 src/Provider/Doctrine/Transaction/TransactionHydrator.php create mode 100644 src/Provider/Doctrine/Transaction/TransactionProcessor.php create mode 100644 src/Provider/Doctrine/Updater/UpdateManager.php create mode 100644 src/Provider/ProviderInterface.php create mode 100644 src/Transaction/TransactionHydratorInterface.php create mode 100644 src/Transaction/TransactionProcessorInterface.php create mode 100644 tests/AuditorTest.php create mode 100644 tests/ConfigurationTest.php create mode 100644 tests/EventSubscriber/AuditEventSubscriberTest.php create mode 100644 tests/Fixtures/DummyProvider.php create mode 100644 tests/Provider/Doctrine/ConfigurationTest.php create mode 100644 tests/Provider/Doctrine/DoctrineProviderTest.php create mode 100644 tests/Provider/Doctrine/Fixtures/Entity/Annotation/AuditedEntity.php create mode 100644 tests/Provider/Doctrine/Fixtures/Entity/Annotation/UnauditedEntity.php create mode 100644 tests/Provider/Doctrine/Fixtures/Entity/Inheritance/Animal.php create mode 100644 tests/Provider/Doctrine/Fixtures/Entity/Inheritance/Bike.php create mode 100644 tests/Provider/Doctrine/Fixtures/Entity/Inheritance/Car.php create mode 100644 tests/Provider/Doctrine/Fixtures/Entity/Inheritance/Cat.php create mode 100644 tests/Provider/Doctrine/Fixtures/Entity/Inheritance/Dog.php create mode 100644 tests/Provider/Doctrine/Fixtures/Entity/Inheritance/Vehicle.php create mode 100644 tests/Provider/Doctrine/Fixtures/Entity/Standard/Author.php create mode 100644 tests/Provider/Doctrine/Fixtures/Entity/Standard/Comment.php create mode 100644 tests/Provider/Doctrine/Fixtures/Entity/Standard/DummyEntity.php create mode 100644 tests/Provider/Doctrine/Fixtures/Entity/Standard/Post.php create mode 100644 tests/Provider/Doctrine/Fixtures/Entity/Standard/Tag.php create mode 100644 tests/Provider/Doctrine/Fixtures/Issue37/Locale.php create mode 100644 tests/Provider/Doctrine/Fixtures/Issue37/User.php create mode 100644 tests/Provider/Doctrine/Fixtures/Issue40/CoreCase.php create mode 100644 tests/Provider/Doctrine/Fixtures/Issue40/DieselCase.php create mode 100644 tests/Provider/Doctrine/Traits/ConnectionTrait.php create mode 100644 tests/Provider/Doctrine/Traits/DoctrineProviderTrait.php create mode 100644 tests/Provider/Doctrine/Traits/EntityManagerInterfaceTrait.php create mode 100644 tests/Provider/Doctrine/Traits/ProviderConfigurationTrait.php create mode 100644 tests/Traits/AuditorConfigurationTrait.php create mode 100644 tests/Traits/AuditorTrait.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..7b942b29 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +.idea/ +.php_cs.cache +.phpunit.result.cache +/vendor/ +/tests/coverage/ +composer.lock diff --git a/.php_cs b/.php_cs new file mode 100644 index 00000000..78a272e2 --- /dev/null +++ b/.php_cs @@ -0,0 +1,71 @@ +setRiskyAllowed(true) + ->setRules([ + '@PhpCsFixer' => true, + '@PhpCsFixer:risky' => true, + '@PHP73Migration' => true, + '@PHP71Migration:risky' => true, + '@DoctrineAnnotation' => true, + '@PHPUnit75Migration:risky' => true, + 'blank_line_before_statement' => [ + 'statements' => [ + 'break', + // 'case', -> On ne souhaite pas ce cas + 'continue', + 'declare', + // 'default', -> On ne souhaite pas ce cas + 'exit', + 'goto', + 'include', + 'include_once', + 'require', + 'require_once', + 'return', + 'switch', + 'throw', + 'try', + ], + ], + 'date_time_immutable' => false, + 'declare_strict_types' => false, + 'general_phpdoc_annotation_remove' => [ + 'annotations' => [ + 'expectedException', + 'expectedExceptionMessage', + 'expectedExceptionMessageRegExp', + ], + ], + 'global_namespace_import' => true, + 'list_syntax' => ['syntax' => 'short'], + 'mb_str_functions' => true, + 'nullable_type_declaration_for_default_null_value' => true, + 'ordered_interfaces' => true, + 'phpdoc_line_span' => true, + // 'phpdoc_to_param_type' => true, + // 'phpdoc_to_return_type' => true, + // 'regular_callable_call' => true, + 'self_static_accessor' => true, + // 'simplified_if_return' => true, // Fait bugger le cs-fixer (local principalement) en version < 3 + 'simplified_null_return' => true, + 'php_unit_test_class_requires_covers' => false, + ]) + ->setFinder(PhpCsFixer\Finder::create()->in(__DIR__)) +; + +// special handling of fabbot.io service if it's using too old PHP CS Fixer version +try { + PhpCsFixer\FixerFactory::create() + ->registerBuiltInFixers() + ->registerCustomFixers($config->getCustomFixers()) + ->useRuleSet(new PhpCsFixer\RuleSet($config->getRules())); +} catch (PhpCsFixer\ConfigurationException\InvalidConfigurationException $e) { + $config->setRules([]); +} catch (UnexpectedValueException $e) { + $config->setRules([]); +} catch (InvalidArgumentException $e) { + $config->setRules([]); +} + +return $config; diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..cf708c05 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright 2020 Damien Harper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..5d338e42 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Auditor library \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..9583aa54 --- /dev/null +++ b/composer.json @@ -0,0 +1,59 @@ +{ + "name": "damienharper/auditor", + "type": "library", + "description": "Audits logs made easy.", + "keywords": ["doctrine", "audit", "audit-log", "audit-trail"], + "license": "MIT", + "authors": [ + { + "name": "Damien Harper", + "email": "damien.harper@gmail.com" + } + ], + "require": { + "php": ">=7.2", + "doctrine/annotations": "^1.8", + "doctrine/doctrine-bundle": "^1.9|^2.0", + "doctrine/orm": "^2.7", + "symfony/event-dispatcher": "^3.4|^4.0|^5.0", + "symfony/options-resolver": "^3.4|^4.0|^5.0" + }, + "autoload": { + "psr-4": { + "DH\\Auditor\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "DH\\Auditor\\Tests\\": "tests" + } + }, + "require-dev": { + "doctrine/doctrine-migrations-bundle": "^1.3|^2.0", + "friendsofphp/php-cs-fixer": "^2.16", + "gedmo/doctrine-extensions": "^2.4", + "korbeil/phpstan-generic-rules": "^0.2", + "phpstan/phpstan": "^0.12", + "phpstan/phpstan-deprecation-rules": "^0.12", + "phpstan/phpstan-doctrine": "^0.12", + "phpstan/phpstan-strict-rules": "^0.12", + "phpunit/phpunit": "^7.0|^8.0", + "symfony/var-dumper": "^4.0|^5.0" + }, + "conflict": { + "doctrine/persistence": "<1.3" + }, + "scripts": { + "test": "php -d pcov.enabled=1 ./vendor/bin/phpunit", + "csfixer": "vendor/bin/php-cs-fixer fix --config=.php_cs --using-cache=no --verbose", + "phpstan": "vendor/bin/phpstan analyse src" + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..07a9bd16 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,28 @@ +includes: + - vendor/phpstan/phpstan-doctrine/extension.neon + - vendor/phpstan/phpstan-doctrine/rules.neon + +parameters: + level: max + inferPrivatePropertyTypeFromConstructor: true + checkMissingIterableValueType: false + checkGenericClassInNonGenericObjectType: false + +# excludes_analyse: +# - '%currentWorkingDirectory%/src/DoctrineAuditBundle/DependencyInjection/Configuration.php' +# +# ignoreErrors: +# # false positives +# - '~Parameter \#1 \$name of method Symfony\\Component\\Console\\Command\\Command\:\:setName\(\) expects string, string\|null given~' +# - '~Parameter \#1 \$tableName of method Doctrine\\DBAL\\Schema\\Schema\:\:(has|get)Table\(\) expects string, string\|null given~' +# - '~Parameter \#1 \$(first|max)Results? of method Doctrine\\DBAL\\Query\\QueryBuilder\:\:set(First|Max)Results?\(\) expects int, null given~' +# - '~Parameter \#1 \$event of method Symfony\\Contracts\\EventDispatcher\\EventDispatcherInterface\:\:dispatch\(\) expects object, string given~' +# - '~Parameter \#2 \$eventName of method Symfony\\Contracts\\EventDispatcher\\EventDispatcherInterface\:\:dispatch\(\) expects string\|null, DH\\DoctrineAuditBundle\\Event\\LifecycleEvent given\.~' +# - '~Cannot call method fetchColumn\(\) on Doctrine\\DBAL\\Driver\\Statement\|int~' +# - '~Cannot call method fetchAll\(\) on Doctrine\\DBAL\\Driver\\Statement\|int~' +# - '~Cannot cast array\\|string\|null to string\.~' +# - '~Cannot cast array\\|string\|null to int\.~' +# - '~Call to an undefined method Symfony\\Component\\Security\\Core\\Authentication\\Token\\TokenInterface\:\:getRoles\(\)\.~' +# - '~Call to method getSource\(\) on an unknown class Symfony\\Component\\Security\\Core\\Role\\SwitchUserRole\.~' +# - '~Class Symfony\\Component\\Security\\Core\\Role\\SwitchUserRole not found\.~' +# - '~Method DH\\DoctrineAuditBundle\\User\\UserInterface\:\:getId\(\) has no return typehint specified\.~' \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000..faa3aa63 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,35 @@ + + + + + + + + + + tests + + + + + + src + + + + + + + + + + + diff --git a/src/Auditor.php b/src/Auditor.php new file mode 100644 index 00000000..6431186a --- /dev/null +++ b/src/Auditor.php @@ -0,0 +1,67 @@ +configuration = $configuration; + $this->provider = $provider; + $this->dispatcher = $dispatcher; + + $this->provider->setAuditor($this); + + $r = new ReflectionMethod($this->dispatcher, 'dispatch'); + $p = $r->getParameters(); + $this->is_pre43_dispatcher = 2 === \count($p) && 'event' !== $p[0]->name; + } + + public function isPre43Dispatcher(): bool + { + return $this->is_pre43_dispatcher; + } + + public function getConfiguration(): Configuration + { + return $this->configuration; + } + + public function getProvider(): ProviderInterface + { + return $this->provider; + } + + public function getEventDispatcher(): EventDispatcherInterface + { + return $this->dispatcher; + } +} diff --git a/src/Configuration.php b/src/Configuration.php new file mode 100644 index 00000000..86ef01c0 --- /dev/null +++ b/src/Configuration.php @@ -0,0 +1,77 @@ +configureOptions($resolver); + $config = $resolver->resolve($options); + + $this->enabled = $config['enabled']; + $this->timezone = $config['timezone']; + } + + public function configureOptions(OptionsResolver $resolver): void + { + // https://symfony.com/doc/current/components/options_resolver.html + $resolver + ->setDefaults([ + 'enabled' => true, + 'timezone' => 'UTC', + ]) + ->setAllowedTypes('enabled', 'bool') + ->setAllowedTypes('timezone', 'string') + ; + } + + /** + * enabled auditing. + */ + public function enable(): self + { + $this->enabled = true; + + return $this; + } + + /** + * disable auditing. + */ + public function disable(): self + { + $this->enabled = false; + + return $this; + } + + /** + * Is auditing enabled. + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * Get the value of timezone. + */ + public function getTimezone(): string + { + return $this->timezone; + } +} diff --git a/src/Event/AuditEvent.php b/src/Event/AuditEvent.php new file mode 100644 index 00000000..e23be75c --- /dev/null +++ b/src/Event/AuditEvent.php @@ -0,0 +1,44 @@ +payload = $payload; + } + + final public function getPayload(): array + { + return $this->payload; + } + } +} else { + abstract class AuditEvent extends ComponentEvent + { + /** + * @var array + */ + private $payload; + + public function __construct(array $payload) + { + $this->payload = $payload; + } + + final public function getPayload(): array + { + return $this->payload; + } + } +} diff --git a/src/Event/LifecycleEvent.php b/src/Event/LifecycleEvent.php new file mode 100644 index 00000000..94500af2 --- /dev/null +++ b/src/Event/LifecycleEvent.php @@ -0,0 +1,7 @@ +auditor = $auditor; + } + + public static function getSubscribedEvents(): array + { + return [ + LifecycleEvent::class => 'onAuditEvent', + ]; + } + + public function onAuditEvent(LifecycleEvent $event): LifecycleEvent + { + $this->auditor->getProvider()->persist($event); + + return $event; + } +} diff --git a/src/Model/Entry.php b/src/Model/Entry.php new file mode 100644 index 00000000..42ae5915 --- /dev/null +++ b/src/Model/Entry.php @@ -0,0 +1,183 @@ +{$name} = $value; + } + + /** + * @return null|int|string + */ + public function __get(string $name) + { + return $this->{$name}; + } + + public function __isset(string $name): bool + { + return property_exists($this, $name); + } + + /** + * Get the value of id. + */ + public function getId(): int + { + return $this->id; + } + + /** + * Get the value of type. + */ + public function getType(): string + { + return $this->type; + } + + /** + * Get the value of object_id. + */ + public function getObjectId(): string + { + return $this->object_id; + } + + /** + * Get the value of discriminator. + */ + public function getDiscriminator(): ?string + { + return $this->discriminator; + } + + /** + * Get the value of transaction_hash. + */ + public function getTransactionHash(): ?string + { + return $this->transaction_hash; + } + + /** + * Get the value of blame_id. + * + * @return null|int|string + */ + public function getUserId() + { + return $this->blame_id; + } + + /** + * Get the value of blame_user. + */ + public function getUsername(): ?string + { + return $this->blame_user; + } + + public function getUserFqdn(): ?string + { + return $this->blame_user_fqdn; + } + + public function getUserFirewall(): ?string + { + return $this->blame_user_firewall; + } + + /** + * Get the value of ip. + * + * @return string + */ + public function getIp(): ?string + { + return $this->ip; + } + + /** + * Get the value of created_at. + */ + public function getCreatedAt(): string + { + return $this->created_at; + } + + /** + * Get the value of created_at. + * + * @return array + */ + public function getDiffs(): ?array + { + return json_decode($this->diffs, true); + } +} diff --git a/src/Model/Transaction.php b/src/Model/Transaction.php new file mode 100644 index 00000000..a370ca95 --- /dev/null +++ b/src/Model/Transaction.php @@ -0,0 +1,84 @@ +transaction_hash) { + $this->transaction_hash = sha1(uniqid('tid', true)); + } + + return $this->transaction_hash; + } + + public function getInserted(): array + { + return $this->inserted; + } + + public function getUpdated(): array + { + return $this->updated; + } + + public function getRemoved(): array + { + return $this->removed; + } + + public function getAssociated(): array + { + return $this->associated; + } + + public function getDissociated(): array + { + return $this->dissociated; + } + + public function trackAuditEvent(string $type, array $data): void + { + $this->{$type}[] = $data; + } +} diff --git a/src/Provider/AbstractProvider.php b/src/Provider/AbstractProvider.php new file mode 100644 index 00000000..d57d575f --- /dev/null +++ b/src/Provider/AbstractProvider.php @@ -0,0 +1,25 @@ +auditor = $auditor; + + return $this; + } + + public function getAuditor(): Auditor + { + return $this->auditor; + } +} diff --git a/src/Provider/Doctrine/Annotation/AnnotationLoader.php b/src/Provider/Doctrine/Annotation/AnnotationLoader.php new file mode 100644 index 00000000..d61e932b --- /dev/null +++ b/src/Provider/Doctrine/Annotation/AnnotationLoader.php @@ -0,0 +1,87 @@ +entityManager = $entityManager; + $this->reader = new AnnotationReader(); + } + + public function load(): array + { + $configuration = []; + + $metadatas = $this->entityManager->getMetadataFactory()->getAllMetadata(); + foreach ($metadatas as $metadata) { + $config = $this->getEntityConfiguration($metadata); + if (null !== $config) { + $configuration[$metadata->getName()] = $config; + } + } + + return $configuration; + } + + private function getEntityConfiguration(ClassMetadata $metadata): ?array + { + $reflection = $metadata->getReflectionClass(); + + // Check that we have an Entity annotation + $annotation = $this->reader->getClassAnnotation($reflection, Entity::class); + if (null === $annotation) { + return null; + } + + // Check that we have an Auditable annotation + /** @var ?Auditable $auditableAnnotation */ + $auditableAnnotation = $this->reader->getClassAnnotation($reflection, Auditable::class); + if (null === $auditableAnnotation) { + return null; + } + + // Check that we have an Security annotation + /** @var ?Security $securityAnnotation */ + $securityAnnotation = $this->reader->getClassAnnotation($reflection, Security::class); + if (null === $securityAnnotation) { + $roles = null; + } else { + $roles = [ + Security::VIEW_SCOPE => $securityAnnotation->view, + ]; + } + + $config = [ + 'ignored_columns' => [], + 'enabled' => $auditableAnnotation->enabled, + 'roles' => $roles, + ]; + + // Are there any Ignore annotations? + foreach ($reflection->getProperties() as $property) { + if ($this->reader->getPropertyAnnotation($property, Ignore::class)) { + // TODO: $property->getName() might not be the column name + $config['ignored_columns'][] = $property->getName(); + } + } + + return $config; + } +} diff --git a/src/Provider/Doctrine/Annotation/Auditable.php b/src/Provider/Doctrine/Annotation/Auditable.php new file mode 100644 index 00000000..77d3724e --- /dev/null +++ b/src/Provider/Doctrine/Annotation/Auditable.php @@ -0,0 +1,20 @@ +"), + * }) + */ +final class Security extends Annotation +{ + public const VIEW_SCOPE = 'view'; + + /** + * @var string + * @Required + */ + public $view; +} diff --git a/src/Provider/Doctrine/Command/CleanAuditLogsCommand.php b/src/Provider/Doctrine/Command/CleanAuditLogsCommand.php new file mode 100644 index 00000000..b2df48cf --- /dev/null +++ b/src/Provider/Doctrine/Command/CleanAuditLogsCommand.php @@ -0,0 +1,166 @@ +container = $container; + } + + public function unlock(): void + { + $this->release(); + } + + protected function configure(): void + { + $this + ->setDescription('Cleans audit tables') + ->setName(self::$defaultName) + ->addOption('no-confirm', null, InputOption::VALUE_NONE, 'No interaction mode') + ->addArgument('keep', InputArgument::OPTIONAL, 'Audits retention period (must be expressed as an ISO 8601 date interval, e.g. P12M to keep the last 12 months or P7D to keep the last 7 days).', 'P12M') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + if (null === $this->container) { + throw new RuntimeException('No container.'); + } + + if (!$this->lock()) { + $output->writeln('The command is already running in another process.'); + + return 0; + } + + $io = new SymfonyStyle($input, $output); + + if (is_numeric($input->getArgument('keep'))) { + $deprecationMessage = "Providing an integer value for the 'keep' argument is deprecated. Please use the ISO 8601 duration format (e.g. P12M)."; + @trigger_error($deprecationMessage, E_USER_DEPRECATED); + $io->writeln($deprecationMessage); + + $keep = (int) $input->getArgument('keep'); + + if ($keep <= 0) { + $io->error("'keep' argument must be a positive number."); + $this->release(); + + return 0; + } + + $until = new DateTime(); + $until->modify('-'.$keep.' month'); + } else { + $keep = (string) ($input->getArgument('keep')); + + try { + $dateInterval = new DateInterval($keep); + } catch (Exception $e) { + $io->error(sprintf("'keep' argument must be a valid ISO 8601 date interval. '%s' given.", $keep)); + $this->release(); + + return 0; + } + + $until = new DateTime(); + $until->sub($dateInterval); + } + + /** + * @var Reader + */ + $reader = $this->container->get('dh_doctrine_audit.reader'); + + /** + * @var Connection + */ + $connection = $reader->getConfiguration()->getEntityManager()->getConnection(); + + $entities = $reader->getEntities(); + + $message = sprintf( + "You are about to clean audits created before %s: %d entities involved.\n Do you want to proceed?", + $until->format('Y-m-d'), + \count($entities) + ); + + $confirm = $input->getOption('no-confirm') ? true : $io->confirm($message, false); + + if ($confirm) { + $progressBar = new ProgressBar($output, \count($entities)); + $progressBar->setBarWidth(70); + $progressBar->setFormat("%message%\n".$progressBar->getFormatDefinition('debug')); + + $progressBar->setMessage('Starting...'); + $progressBar->start(); + + foreach ($entities as $entity => $tablename) { + $auditTable = implode('', [ + $reader->getConfiguration()->getTablePrefix(), + $tablename, + $reader->getConfiguration()->getTableSuffix(), + ]); + + /** + * @var QueryBuilder + */ + $queryBuilder = $connection->createQueryBuilder(); + $queryBuilder + ->delete($auditTable) + ->where('created_at < :until') + ->setParameter(':until', $until->format('Y-m-d')) + ->execute() + ; + + $progressBar->setMessage("Cleaning audit tables... ({$auditTable})"); + $progressBar->advance(); + } + + $progressBar->setMessage('Cleaning audit tables... (done)'); + $progressBar->display(); + + $io->newLine(2); + + $io->success('Success.'); + } else { + $io->success('Cancelled.'); + } + + // if not released explicitly, Symfony releases the lock + // automatically when the execution of the command ends + $this->release(); + + return 0; + } +} diff --git a/src/Provider/Doctrine/Command/UpdateSchemaCommand.php b/src/Provider/Doctrine/Command/UpdateSchemaCommand.php new file mode 100644 index 00000000..1538910c --- /dev/null +++ b/src/Provider/Doctrine/Command/UpdateSchemaCommand.php @@ -0,0 +1,148 @@ +container = $container; + } + + public function unlock(): void + { + $this->release(); + } + + protected function configure(): void + { + $this + ->setDescription('Update audit tables structure') + ->addOption('dump-sql', null, InputOption::VALUE_NONE, 'Dumps the generated SQL statements to the screen (does not execute them).') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Causes the generated SQL statements to be physically executed against your database.') + ->setName(self::$defaultName) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + if (null === $this->container) { + throw new RuntimeException('No container.'); + } + + if (!$this->lock()) { + $output->writeln('The command is already running in another process.'); + + return 0; + } + + $io = new SymfonyStyle($input, $output); + + $dumpSql = true === $input->getOption('dump-sql'); + $force = true === $input->getOption('force'); + + /** + * @var TransactionManager + */ + $manager = $this->container->get('dh_doctrine_audit.manager'); + + /** + * @var Reader + */ + $reader = $this->container->get('dh_doctrine_audit.reader'); + + /** + * @var \DH\Auditor\Provider\Doctrine\Updater\UpdateManager + */ + $updater = new UpdateManager($manager, $reader); + + $sqls = $updater->getUpdateAuditSchemaSql(); + + if (empty($sqls)) { + $io->success('Nothing to update.'); + + $this->release(); + + return 0; + } + + if ($dumpSql) { + $io->text('The following SQL statements will be executed:'); + $io->newLine(); + + foreach ($sqls as $sql) { + $io->text(sprintf(' %s;', $sql)); + } + } + + if ($force) { + if ($dumpSql) { + $io->newLine(); + } + $io->text('Updating database schema...'); + $io->newLine(); + + $progressBar = new ProgressBar($output, \count($sqls)); + $progressBar->start(); + + $updater->updateAuditSchema($sqls, static function (array $progress) use ($progressBar): void { + $progressBar->advance(); + }); + + $progressBar->finish(); + + $io->newLine(2); + + $pluralization = (1 === \count($sqls)) ? 'query was' : 'queries were'; + + $io->text(sprintf(' %s %s executed', \count($sqls), $pluralization)); + $io->success('Database schema updated successfully!'); + } + + if ($dumpSql || $force) { + $this->release(); + + return 0; + } + + $io->caution('This operation should not be executed in a production environment!'); + + $io->text( + [ + sprintf('The Schema-Tool would execute "%s" queries to update the database.', \count($sqls)), + '', + 'Please run the operation by passing one - or both - of the following options:', + '', + sprintf(' %s --force to execute the command', $this->getName()), + sprintf(' %s --dump-sql to dump the SQL statements to the screen', $this->getName()), + ] + ); + + $this->release(); + + return 1; + } +} diff --git a/src/Provider/Doctrine/Configuration.php b/src/Provider/Doctrine/Configuration.php new file mode 100644 index 00000000..9589eb71 --- /dev/null +++ b/src/Provider/Doctrine/Configuration.php @@ -0,0 +1,188 @@ +configureOptions($resolver); + $config = $resolver->resolve($options); + + $this->enabledViewer = $config['enabled_viewer']; + $this->tablePrefix = $config['table_prefix']; + $this->tableSuffix = $config['table_suffix']; + $this->ignoredColumns = $config['ignored_columns']; + + if (isset($config['entities']) && !empty($config['entities'])) { + // use entity names as array keys for easier lookup + foreach ($config['entities'] as $auditedEntity => $entityOptions) { + $this->entities[$auditedEntity] = $entityOptions; + } + } + } + + public function configureOptions(OptionsResolver $resolver): void + { + // https://symfony.com/doc/current/components/options_resolver.html + $resolver + ->setDefaults([ + 'table_prefix' => '', + 'table_suffix' => '_audit', + 'ignored_columns' => [], + 'entities' => [], + 'enabled_viewer' => true, + ]) + ->setAllowedTypes('table_prefix', 'string') + ->setAllowedTypes('table_suffix', 'string') + ->setAllowedTypes('ignored_columns', 'array') + ->setAllowedTypes('entities', 'array') + ->setAllowedTypes('enabled_viewer', 'bool') + ; + } + + /** + * Set the value of entities. + * + * This method completely overrides entities configuration + * including annotation configuration + * + * @param array $entities + */ + public function setEntities(array $entities): void + { + $this->annotationLoaded = true; + $this->entities = $entities; + } + + /** + * enable audit Controller and its routing. + * + * @return $this + */ + public function enableViewer(): self + { + $this->enabledViewer = true; + + return $this; + } + + /** + * disable audit Controller and its routing. + * + * @return $this + */ + public function disableViewer(): self + { + $this->enabledViewer = false; + + return $this; + } + + /** + * Get enabled flag. + */ + public function isEnabledViewer(): bool + { + return $this->enabledViewer; + } + + /** + * Get the value of tablePrefix. + */ + public function getTablePrefix(): string + { + return $this->tablePrefix; + } + + /** + * Get the value of tableSuffix. + */ + public function getTableSuffix(): string + { + return $this->tableSuffix; + } + + /** + * Get the value of excludedColumns. + * + * @return array + */ + public function getIgnoredColumns(): array + { + return $this->ignoredColumns; + } + + /** + * Get the value of entities. + */ + public function getEntities(): array + { + return $this->entities; + } + + /** + * Enables auditing for a specific entity. + * + * @param string $entity Entity class name + * + * @return $this + */ + public function enableAuditFor(string $entity): self + { + if (isset($this->getEntities()[$entity])) { + $this->entities[$entity]['enabled'] = true; + } + + return $this; + } + + /** + * Disables auditing for a specific entity. + * + * @param string $entity Entity class name + * + * @return $this + */ + public function disableAuditFor(string $entity): self + { + if (isset($this->getEntities()[$entity])) { + $this->entities[$entity]['enabled'] = false; + } + + return $this; + } +} diff --git a/src/Provider/Doctrine/DoctrineProvider.php b/src/Provider/Doctrine/DoctrineProvider.php new file mode 100644 index 00000000..d91b0e4b --- /dev/null +++ b/src/Provider/Doctrine/DoctrineProvider.php @@ -0,0 +1,130 @@ +configuration = $configuration; + $this->annotationLoader = $annotationLoader; + + $this->configuration->setEntities(array_merge( + $this->configuration->getEntities(), + $this->annotationLoader->load() + )); + } + + public function persist(LifecycleEvent $event): void + { + // TODO: Implement persist() method. + } + + public function getAnnotationLoader(): AnnotationLoader + { + return $this->annotationLoader; + } + + /** + * Returns true if $entity is auditable. + * + * @param object|string $entity + */ + public function isAuditable($entity): bool + { + $class = DoctrineHelper::getRealClassName($entity); + + // is $entity part of audited entities? + if (!\array_key_exists($class, $this->configuration->getEntities())) { + // no => $entity is not audited + return false; + } + + return true; + } + + /** + * Returns true if $entity is audited. + * + * @param object|string $entity + */ + public function isAudited($entity): bool + { + if (!$this->auditor->getConfiguration()->isEnabled()) { + return false; + } + + $class = DoctrineHelper::getRealClassName($entity); + + // is $entity part of audited entities? + if (!\array_key_exists($class, $this->configuration->getEntities())) { + // no => $entity is not audited + return false; + } + + $entityOptions = $this->configuration->getEntities()[$class]; + + if (null === $entityOptions) { + // no option defined => $entity is audited + return true; + } + + if (isset($entityOptions['enabled'])) { + return (bool) $entityOptions['enabled']; + } + + return true; + } + + /** + * Returns true if $field is audited. + * + * @param object|string $entity + */ + public function isAuditedField($entity, string $field): bool + { + // is $field is part of globally ignored columns? + if (\in_array($field, $this->configuration->getIgnoredColumns(), true)) { + // yes => $field is not audited + return false; + } + + // is $entity audited? + if (!$this->isAudited($entity)) { + // no => $field is not audited + return false; + } + + $class = DoctrineHelper::getRealClassName($entity); + $entityOptions = $this->configuration->getEntities()[$class]; + + if (null === $entityOptions) { + // no option defined => $field is audited + return true; + } + + // are columns excluded and is field part of them? + if (isset($entityOptions['ignored_columns']) && + \in_array($field, $entityOptions['ignored_columns'], true)) { + // yes => $field is not audited + return false; + } + + return true; + } +} diff --git a/src/Provider/Doctrine/Event/AuditSubscriber.php b/src/Provider/Doctrine/Event/AuditSubscriber.php new file mode 100644 index 00000000..79cc212f --- /dev/null +++ b/src/Provider/Doctrine/Event/AuditSubscriber.php @@ -0,0 +1,68 @@ +transactionManager = $transactionManager; + } + + public static function getSubscribedEvents(): array + { + return [ + LifecycleEvent::class => 'onAuditEvent', + ]; + } + + /** + * @throws \Doctrine\DBAL\DBALException + */ + public function onAuditEvent(LifecycleEvent $event): LifecycleEvent + { + $payload = $event->getPayload(); + $auditTable = $payload['table']; + unset($payload['table'], $payload['entity']); + + $fields = [ + 'type' => ':type', + 'object_id' => ':object_id', + 'discriminator' => ':discriminator', + 'transaction_hash' => ':transaction_hash', + 'diffs' => ':diffs', + 'blame_id' => ':blame_id', + 'blame_user' => ':blame_user', + 'blame_user_fqdn' => ':blame_user_fqdn', + 'blame_user_firewall' => ':blame_user_firewall', + 'ip' => ':ip', + 'created_at' => ':created_at', + ]; + + $query = sprintf( + 'INSERT INTO %s (%s) VALUES (%s)', + $auditTable, + implode(', ', array_keys($fields)), + implode(', ', array_values($fields)) + ); + + $storage = $this->transactionManager->selectStorageSpace($this->transactionManager->getConfiguration()->getEntityManager()); + $statement = $storage->getConnection()->prepare($query); + + foreach ($payload as $key => $value) { + $statement->bindValue($key, $value); + } + + $statement->execute(); + + return $event; + } +} diff --git a/src/Provider/Doctrine/Event/CreateSchemaListener.php b/src/Provider/Doctrine/Event/CreateSchemaListener.php new file mode 100644 index 00000000..6f4b4e27 --- /dev/null +++ b/src/Provider/Doctrine/Event/CreateSchemaListener.php @@ -0,0 +1,81 @@ +transactionManager = $transactionManager; + $this->reader = $reader; + } + + public function postGenerateSchemaTable(GenerateSchemaTableEventArgs $eventArgs): void + { + $metadata = $eventArgs->getClassMetadata(); + + // check inheritance type and returns if unsupported + if (!\in_array($metadata->inheritanceType, [ + ClassMetadataInfo::INHERITANCE_TYPE_NONE, + ClassMetadataInfo::INHERITANCE_TYPE_JOINED, + ClassMetadataInfo::INHERITANCE_TYPE_SINGLE_TABLE, + ], true)) { + throw new Exception(sprintf('Inheritance type "%s" is not yet supported', $metadata->inheritanceType)); + } + + // check reader and manager entity managers and returns if different + if ($this->reader->getEntityManager() !== $this->transactionManager->getConfiguration()->getEntityManager()) { + return; + } + + // check if entity or its children are audited + if (!$this->transactionManager->getConfiguration()->isAuditable($metadata->name)) { + $audited = false; + if ( + $metadata->rootEntityName === $metadata->name && + ClassMetadataInfo::INHERITANCE_TYPE_SINGLE_TABLE === $metadata->inheritanceType + ) { + foreach ($metadata->subClasses as $subClass) { + if ($this->transactionManager->getConfiguration()->isAuditable($subClass)) { + $audited = true; + } + } + } + if (!$audited) { + return; + } + } + + $updater = new UpdateManager($this->transactionManager, $this->reader); + $updater->createAuditTable($eventArgs->getClassTable(), $eventArgs->getSchema()); + } + + /** + * {@inheritdoc} + */ + public function getSubscribedEvents() + { + return [ + ToolEvents::postGenerateSchemaTable, + ]; + } +} diff --git a/src/Provider/Doctrine/Event/DoctrineSubscriber.php b/src/Provider/Doctrine/Event/DoctrineSubscriber.php new file mode 100644 index 00000000..45777734 --- /dev/null +++ b/src/Provider/Doctrine/Event/DoctrineSubscriber.php @@ -0,0 +1,77 @@ +transactionManager = $transactionManager; + } + + /** + * It is called inside EntityManager#flush() after the changes to all the managed entities + * and their associations have been computed. + * + * @see https://www.doctrine-project.org/projects/doctrine-orm/en/latest/reference/events.html#onflush + * + * @throws \Doctrine\DBAL\DBALException + * @throws \Doctrine\ORM\Mapping\MappingException + */ + public function onFlush(OnFlushEventArgs $args): void + { + $em = $args->getEntityManager(); + $transaction = new Transaction(); + + // extend the SQL logger + $this->loggerBackup = $em->getConnection()->getConfiguration()->getSQLLogger(); + $auditLogger = new Logger(function () use ($em, $transaction): void { + // flushes pending data + $em->getConnection()->getConfiguration()->setSQLLogger($this->loggerBackup); + $this->transactionManager->process($transaction); + }); + + // Initialize a new LoggerChain with the new AuditLogger + the existing SQLLoggers. + $loggerChain = new LoggerChain(); + $loggerChain->addLogger($auditLogger); + if ($this->loggerBackup instanceof LoggerChain) { + /** @var SQLLogger $logger */ + foreach ($this->loggerBackup->getLoggers() as $logger) { + $loggerChain->addLogger($logger); + } + } elseif ($this->loggerBackup instanceof SQLLogger) { + $loggerChain->addLogger($this->loggerBackup); + } + $em->getConnection()->getConfiguration()->setSQLLogger($loggerChain); + + // Populate transaction + $this->transactionManager->populate($transaction); + } + + /** + * {@inheritdoc} + */ + public function getSubscribedEvents(): array + { + return [Events::onFlush]; + } +} diff --git a/src/Provider/Doctrine/Exception/InvalidArgumentException.php b/src/Provider/Doctrine/Exception/InvalidArgumentException.php new file mode 100644 index 00000000..e2fcbc6a --- /dev/null +++ b/src/Provider/Doctrine/Exception/InvalidArgumentException.php @@ -0,0 +1,9 @@ += 2.0) + $positionCg = mb_strrpos($class, '\\__CG__\\'); + $positionPm = mb_strrpos($class, '\\__PM__\\'); + if ((false === $positionCg) && + (false === $positionPm)) { + return $class; + } + if (false !== $positionCg) { + return mb_substr($class, $positionCg + 8); + } + $className = ltrim($class, '\\'); + + return mb_substr( + $className, + 8 + $positionPm, + mb_strrpos($className, '\\') - ($positionPm + 8) + ); + } + + public static function getDoctrineType(string $type): string + { + return \constant((class_exists(Types::class, false) ? Types::class : Type::class).'::'.$type); + } +} diff --git a/src/Provider/Doctrine/Helper/SchemaHelper.php b/src/Provider/Doctrine/Helper/SchemaHelper.php new file mode 100644 index 00000000..d60e6634 --- /dev/null +++ b/src/Provider/Doctrine/Helper/SchemaHelper.php @@ -0,0 +1,141 @@ + [ + 'type' => DoctrineHelper::getDoctrineType('INTEGER'), + 'options' => [ + 'autoincrement' => true, + 'unsigned' => true, + ], + ], + 'type' => [ + 'type' => DoctrineHelper::getDoctrineType('STRING'), + 'options' => [ + 'notnull' => true, + 'length' => 10, + ], + ], + 'object_id' => [ + 'type' => DoctrineHelper::getDoctrineType('STRING'), + 'options' => [ + 'notnull' => true, + ], + ], + 'discriminator' => [ + 'type' => DoctrineHelper::getDoctrineType('STRING'), + 'options' => [ + 'default' => null, + 'notnull' => false, + ], + ], + 'transaction_hash' => [ + 'type' => DoctrineHelper::getDoctrineType('STRING'), + 'options' => [ + 'notnull' => false, + 'length' => 40, + ], + ], + 'diffs' => [ + 'type' => DoctrineHelper::getDoctrineType('JSON'), + 'options' => [ + 'default' => null, + 'notnull' => false, + ], + ], + 'blame_id' => [ + 'type' => DoctrineHelper::getDoctrineType('STRING'), + 'options' => [ + 'default' => null, + 'notnull' => false, + ], + ], + 'blame_user' => [ + 'type' => DoctrineHelper::getDoctrineType('STRING'), + 'options' => [ + 'default' => null, + 'notnull' => false, + 'length' => 255, + ], + ], + 'blame_user_fqdn' => [ + 'type' => DoctrineHelper::getDoctrineType('STRING'), + 'options' => [ + 'default' => null, + 'notnull' => false, + 'length' => 255, + ], + ], + 'blame_user_firewall' => [ + 'type' => DoctrineHelper::getDoctrineType('STRING'), + 'options' => [ + 'default' => null, + 'notnull' => false, + 'length' => 100, + ], + ], + 'ip' => [ + 'type' => DoctrineHelper::getDoctrineType('STRING'), + 'options' => [ + 'default' => null, + 'notnull' => false, + 'length' => 45, + ], + ], + 'created_at' => [ + 'type' => DoctrineHelper::getDoctrineType('DATETIME_IMMUTABLE'), + 'options' => [ + 'notnull' => true, + ], + ], + ]; + } + + /** + * Return indices of an audit table. + */ + public static function getAuditTableIndices(string $tablename): array + { + return [ + 'id' => [ + 'type' => 'primary', + ], + 'type' => [ + 'type' => 'index', + 'name' => 'type_'.md5($tablename).'_idx', + ], + 'object_id' => [ + 'type' => 'index', + 'name' => 'object_id_'.md5($tablename).'_idx', + ], + 'discriminator' => [ + 'type' => 'index', + 'name' => 'discriminator_'.md5($tablename).'_idx', + ], + 'transaction_hash' => [ + 'type' => 'index', + 'name' => 'transaction_hash_'.md5($tablename).'_idx', + ], + 'blame_id' => [ + 'type' => 'index', + 'name' => 'blame_id_'.md5($tablename).'_idx', + ], + 'created_at' => [ + 'type' => 'index', + 'name' => 'created_at_'.md5($tablename).'_idx', + ], + ]; + } +} diff --git a/src/Provider/Doctrine/Logger/Logger.php b/src/Provider/Doctrine/Logger/Logger.php new file mode 100644 index 00000000..27161c54 --- /dev/null +++ b/src/Provider/Doctrine/Logger/Logger.php @@ -0,0 +1,36 @@ +flusher = $flusher; + } + + /** + * {@inheritdoc} + */ + public function startQuery($sql, ?array $params = null, ?array $types = null): void + { + // right before commit insert all audit entries + if ('"COMMIT"' === $sql) { + \call_user_func($this->flusher); + } + } + + /** + * {@inheritdoc} + */ + public function stopQuery(): void + { + } +} diff --git a/src/Provider/Doctrine/Logger/LoggerChain.php b/src/Provider/Doctrine/Logger/LoggerChain.php new file mode 100644 index 00000000..a5be2c18 --- /dev/null +++ b/src/Provider/Doctrine/Logger/LoggerChain.php @@ -0,0 +1,49 @@ +loggers[] = $logger; + } + + /** + * {@inheritdoc} + */ + public function startQuery($sql, ?array $params = null, ?array $types = null): void + { + foreach ($this->loggers as $logger) { + $logger->startQuery($sql, $params, $types); + } + } + + /** + * {@inheritdoc} + */ + public function stopQuery(): void + { + foreach ($this->loggers as $logger) { + $logger->stopQuery(); + } + } + + /** + * @return SQLLogger[] + */ + public function getLoggers() + { + return $this->loggers; + } +} diff --git a/src/Provider/Doctrine/Reader/Paginator.php b/src/Provider/Doctrine/Reader/Paginator.php new file mode 100644 index 00000000..cbe34748 --- /dev/null +++ b/src/Provider/Doctrine/Reader/Paginator.php @@ -0,0 +1,69 @@ +queryBuilder = $queryBuilder; + } + + public function count(): int + { + $queryBuilder = $this->cloneQuery($this->queryBuilder); + + $result = $queryBuilder + ->resetQueryPart('select') + ->resetQueryPart('orderBy') + ->setMaxResults(null) + ->setFirstResult(null) + ->select('COUNT(id)') + ->execute() + ->fetchColumn(0) + ; + + $this->count = false === $result ? 0 : $result; + + return $this->count; + } + + public function getIterator() + { + $offset = $this->queryBuilder->getFirstResult(); + $length = $this->queryBuilder->getMaxResults(); + + $result = $this->cloneQuery($this->queryBuilder) + ->setMaxResults($length) + ->setFirstResult($offset) + ->execute() + ->fetchAll() + ; + + return new ArrayIterator($result); + } + + private function cloneQuery(QueryBuilder $queryBuilder): QueryBuilder + { + /** @var QueryBuilder $cloneQuery */ + $cloneQuery = clone $queryBuilder; + $cloneQuery->setParameters($queryBuilder->getParameters(), $queryBuilder->getParameterTypes()); + + return $cloneQuery; + } +} diff --git a/src/Provider/Doctrine/Reader/Reader.php b/src/Provider/Doctrine/Reader/Reader.php new file mode 100644 index 00000000..a984ee43 --- /dev/null +++ b/src/Provider/Doctrine/Reader/Reader.php @@ -0,0 +1,469 @@ +configuration = $configuration; + $this->entityManager = $entityManager; + } + + public function getConfiguration(): Configuration + { + return $this->configuration; + } + + /** + * Set the filter(s) for AuditEntry retrieving. + * + * @param array|string $filter + * + * @return Reader + */ + public function filterBy($filter): self + { + $filters = \is_array($filter) ? $filter : [$filter]; + + $this->filters = array_filter($filters, static function ($f) { + return \in_array($f, [self::UPDATE, self::ASSOCIATE, self::DISSOCIATE, self::INSERT, self::REMOVE], true); + }); + + return $this; + } + + /** + * Returns current filter. + */ + public function getFilters(): array + { + return $this->filters; + } + + /** + * Returns an array of audit table names indexed by entity FQN. + * + * @throws \Doctrine\ORM\ORMException + */ + public function getEntities(): array + { + $metadataDriver = $this->entityManager->getConfiguration()->getMetadataDriverImpl(); + $entities = []; + if (null !== $metadataDriver) { + $entities = $metadataDriver->getAllClassNames(); + } + $audited = []; + foreach ($entities as $entity) { + if ($this->configuration->isAuditable($entity)) { + $audited[$entity] = $this->getEntityTableName($entity); + } + } + ksort($audited); + + return $audited; + } + + /** + * Returns an array of audited entries/operations. + * + * @param null|int|string $id + * + * @throws AccessDeniedException + * @throws InvalidArgumentException + */ + public function getAudits(string $entity, $id = null, ?int $page = null, ?int $pageSize = null, ?string $transactionHash = null, bool $strict = true): array + { + $this->checkAuditable($entity); + $this->checkRoles($entity, Security::VIEW_SCOPE); + + $queryBuilder = $this->getAuditsQueryBuilder($entity, $id, $page, $pageSize, $transactionHash, $strict); + + /** @var Statement $statement */ + $statement = $queryBuilder->execute(); + $statement->setFetchMode(PDO::FETCH_CLASS, Entry::class); + + return $statement->fetchAll(); + } + + /** + * Returns an array of all audited entries/operations for a given transaction hash + * indexed by entity FQCN. + * + * @throws InvalidArgumentException + * @throws \Doctrine\ORM\ORMException + */ + public function getAuditsByTransactionHash(string $transactionHash): array + { + $results = []; + + $entities = $this->getEntities(); + foreach ($entities as $entity => $tablename) { + try { + $audits = $this->getAudits($entity, null, null, null, $transactionHash); + if (\count($audits) > 0) { + $results[$entity] = $audits; + } + } catch (AccessDeniedException $e) { + // acces denied + } + } + + return $results; + } + + /** + * Returns an array of audited entries/operations. + * + * @param null|int|string $id + * @param null|DateTime $startDate - Expected in configured timezone + * @param null|DateTime $endDate - Expected in configured timezone + * + * @throws AccessDeniedException + * @throws InvalidArgumentException + */ + public function getAuditsByDate(string $entity, $id = null, ?DateTime $startDate = null, ?DateTime $endDate = null, ?int $page = null, ?int $pageSize = null, ?string $transactionHash = null, bool $strict = true): array + { + $this->checkAuditable($entity); + $this->checkRoles($entity, Security::VIEW_SCOPE); + + $queryBuilder = $this->getAuditsQueryBuilder($entity, $id, $page, $pageSize, $transactionHash, $strict, $startDate, $endDate); + + /** @var Statement $statement */ + $statement = $queryBuilder->execute(); + $statement->setFetchMode(PDO::FETCH_CLASS, Entry::class); + + return $statement->fetchAll(); + } + + /** + * Returns an array of audited entries/operations. + * + * @param null|int|string $id + * + * @throws AccessDeniedException + * @throws InvalidArgumentException + */ + public function getAuditsPager(string $entity, $id = null, int $page = 1, int $pageSize = self::PAGE_SIZE): array + { + $queryBuilder = $this->getAuditsQueryBuilder($entity, $id, $page, $pageSize); + + $paginator = new Paginator($queryBuilder); + $numResults = $paginator->count(); + + $currentPage = $page < 1 ? 1 : $page; + $hasPreviousPage = $currentPage > 1; + $hasNextPage = ($currentPage * $pageSize) < $numResults; + + return [ + 'results' => $paginator->getIterator(), + 'currentPage' => $currentPage, + 'hasPreviousPage' => $hasPreviousPage, + 'hasNextPage' => $hasNextPage, + 'previousPage' => $hasPreviousPage ? $currentPage - 1 : null, + 'nextPage' => $hasNextPage ? $currentPage + 1 : null, + 'numPages' => (int) ceil($numResults / $pageSize), + 'haveToPaginate' => $numResults > $pageSize, + ]; + } + + /** + * Returns the amount of audited entries/operations. + * + * @param null|int|string $id + * + * @throws AccessDeniedException + * @throws InvalidArgumentException + */ + public function getAuditsCount(string $entity, $id = null): int + { + $queryBuilder = $this->getAuditsQueryBuilder($entity, $id); + + $result = $queryBuilder + ->resetQueryPart('select') + ->resetQueryPart('orderBy') + ->select('COUNT(id)') + ->execute() + ->fetchColumn(0) + ; + + return false === $result ? 0 : $result; + } + + /** + * @param string $id + * + * @throws AccessDeniedException + * @throws InvalidArgumentException + * + * @return mixed[] + */ + public function getAudit(string $entity, $id): array + { + $this->checkAuditable($entity); + $this->checkRoles($entity, Security::VIEW_SCOPE); + + $connection = $this->entityManager->getConnection(); + + /** + * @var \Doctrine\DBAL\Query\QueryBuilder + */ + $queryBuilder = $connection->createQueryBuilder(); + $queryBuilder + ->select('*') + ->from($this->getEntityAuditTableName($entity)) + ->where('id = :id') + ->setParameter('id', $id) + ; + + $this->filterByType($queryBuilder, $this->filters); + + /** @var Statement $statement */ + $statement = $queryBuilder->execute(); + $statement->setFetchMode(PDO::FETCH_CLASS, Entry::class); + + return $statement->fetchAll(); + } + + /** + * Returns the table name of $entity. + */ + public function getEntityTableName(string $entity): string + { + return $this->entityManager->getClassMetadata($entity)->getTableName(); + } + + /** + * Returns the audit table name for $entity. + */ + public function getEntityAuditTableName(string $entity): string + { + $schema = ''; + if ($this->entityManager->getClassMetadata($entity)->getSchemaName()) { + $schema = $this->entityManager->getClassMetadata($entity)->getSchemaName().'.'; + } + + return sprintf('%s%s%s%s', $schema, $this->configuration->getTablePrefix(), $this->getEntityTableName($entity), $this->configuration->getTableSuffix()); + } + + public function getEntityManager(): EntityManagerInterface + { + return $this->entityManager; + } + + private function filterByType(QueryBuilder $queryBuilder, array $filters): QueryBuilder + { + if (!empty($filters)) { + $queryBuilder + ->andWhere('type IN (:filters)') + ->setParameter('filters', $filters, Connection::PARAM_STR_ARRAY) + ; + } + + return $queryBuilder; + } + + private function filterByTransaction(QueryBuilder $queryBuilder, ?string $transactionHash): QueryBuilder + { + if (null !== $transactionHash) { + $queryBuilder + ->andWhere('transaction_hash = :transaction_hash') + ->setParameter('transaction_hash', $transactionHash) + ; + } + + return $queryBuilder; + } + + private function filterByDate(QueryBuilder $queryBuilder, ?DateTime $startDate, ?DateTime $endDate): QueryBuilder + { + if (null !== $startDate && null !== $endDate && $endDate < $startDate) { + throw new \InvalidArgumentException('$endDate must be greater than $startDate.'); + } + + if (null !== $startDate) { + $queryBuilder + ->andWhere('created_at >= :start_date') + ->setParameter('start_date', $startDate->format('Y-m-d H:i:s')) + ; + } + + if (null !== $endDate) { + $queryBuilder + ->andWhere('created_at <= :end_date') + ->setParameter('end_date', $endDate->format('Y-m-d H:i:s')) + ; + } + + return $queryBuilder; + } + + /** + * @param null|int|string $id + */ + private function filterByObjectId(QueryBuilder $queryBuilder, $id): QueryBuilder + { + if (null !== $id) { + $queryBuilder + ->andWhere('object_id = :object_id') + ->setParameter('object_id', $id) + ; + } + + return $queryBuilder; + } + + /** + * Returns an array of audited entries/operations. + * + * @param null|int|string $id + * + * @throws AccessDeniedException + * @throws InvalidArgumentException + */ + private function getAuditsQueryBuilder(string $entity, $id = null, ?int $page = null, ?int $pageSize = null, ?string $transactionHash = null, bool $strict = true, ?DateTime $startDate = null, ?DateTime $endDate = null): QueryBuilder + { + $this->checkAuditable($entity); + $this->checkRoles($entity, Security::VIEW_SCOPE); + + if (null !== $page && $page < 1) { + throw new \InvalidArgumentException('$page must be greater or equal than 1.'); + } + + if (null !== $pageSize && $pageSize < 1) { + throw new \InvalidArgumentException('$pageSize must be greater or equal than 1.'); + } + + $storage = $this->configuration->getEntityManager() ?? $this->entityManager; + $connection = $storage->getConnection(); + + $queryBuilder = $connection->createQueryBuilder(); + $queryBuilder + ->select('*') + ->from($this->getEntityAuditTableName($entity), 'at') + ->orderBy('created_at', 'DESC') + ->addOrderBy('id', 'DESC') + ; + + $metadata = $this->entityManager->getClassMetadata($entity); + if ($strict && $metadata instanceof ORMMetadata && ORMMetadata::INHERITANCE_TYPE_SINGLE_TABLE === $metadata->inheritanceType) { + $queryBuilder + ->andWhere('discriminator = :discriminator') + ->setParameter('discriminator', $entity) + ; + } + + $this->filterByObjectId($queryBuilder, $id); + $this->filterByType($queryBuilder, $this->filters); + $this->filterByTransaction($queryBuilder, $transactionHash); + $this->filterByDate($queryBuilder, $startDate, $endDate); + + if (null !== $pageSize) { + $queryBuilder + ->setFirstResult(($page - 1) * $pageSize) + ->setMaxResults($pageSize) + ; + } + + return $queryBuilder; + } + + /** + * Throws an InvalidArgumentException if given entity is not auditable. + * + * @throws InvalidArgumentException + */ + private function checkAuditable(string $entity): void + { + if (!$this->configuration->isAuditable($entity)) { + throw new InvalidArgumentException('Entity '.$entity.' is not auditable.'); + } + } + + /** + * Throws an AccessDeniedException if user not is granted to access audits for the given entity. + * + * @throws AccessDeniedException + */ + private function checkRoles(string $entity, string $scope): void + { + $userProvider = $this->configuration->getUserProvider(); + $user = null === $userProvider ? null : $userProvider->getUser(); + $security = null === $userProvider ? null : $userProvider->getSecurity(); + + if (!($user instanceof UserInterface) || !($security instanceof CoreSecurity)) { + // If no security defined or no user identified, consider access granted + return; + } + + $entities = $this->configuration->getEntities(); + + $roles = $entities[$entity]['roles'] ?? null; + + if (null === $roles) { + // If no roles are configured, consider access granted + return; + } + + $scope = $roles[$scope] ?? null; + + if (null === $scope) { + // If no roles for the given scope are configured, consider access granted + return; + } + + // roles are defined for the give scope + foreach ($scope as $role) { + if ($security->isGranted($role)) { + // role granted => access granted + return; + } + } + + // access denied + throw new AccessDeniedException('You are not allowed to access audits of '.$entity.' entity.'); + } +} diff --git a/src/Provider/Doctrine/Transaction/AuditTrait.php b/src/Provider/Doctrine/Transaction/AuditTrait.php new file mode 100644 index 00000000..7fe884d6 --- /dev/null +++ b/src/Provider/Doctrine/Transaction/AuditTrait.php @@ -0,0 +1,215 @@ +getClassMetadata(DoctrineHelper::getRealClassName($entity)); + $pk = $meta->getSingleIdentifierFieldName(); + + if (isset($meta->fieldMappings[$pk])) { + $type = Type::getType($meta->fieldMappings[$pk]['type']); + + return $this->value($em, $type, $meta->getReflectionProperty($pk)->getValue($entity)); + } + + /** + * Primary key is not part of fieldMapping. + * + * @see https://github.com/DamienHarper/Auditor\Provider\Doctrine/issues/40 + * @see https://www.doctrine-project.org/projects/doctrine-orm/en/latest/tutorials/composite-primary-keys.html#identity-through-foreign-entities + * We try to get it from associationMapping (will throw a MappingException if not available) + */ + $targetEntity = $meta->getReflectionProperty($pk)->getValue($entity); + + $mapping = $meta->getAssociationMapping($pk); + + /** @var ClassMetadata $meta */ + $meta = $em->getClassMetadata($mapping['targetEntity']); + $pk = $meta->getSingleIdentifierFieldName(); + $type = Type::getType($meta->fieldMappings[$pk]['type']); + + return $this->value($em, $type, $meta->getReflectionProperty($pk)->getValue($targetEntity)); + } + + /** + * Type converts the input value and returns it. + * + * @param mixed $value + * + * @throws \Doctrine\DBAL\DBALException + * + * @return mixed + */ + private function value(EntityManagerInterface $em, Type $type, $value) + { + if (null === $value) { + return; + } + + $platform = $em->getConnection()->getDatabasePlatform(); + + switch ($type->getName()) { + case DoctrineHelper::getDoctrineType('BIGINT'): + $convertedValue = (string) $value; + + break; + case DoctrineHelper::getDoctrineType('INTEGER'): + case DoctrineHelper::getDoctrineType('SMALLINT'): + $convertedValue = (int) $value; + + break; + case DoctrineHelper::getDoctrineType('DECIMAL'): + case DoctrineHelper::getDoctrineType('FLOAT'): + case DoctrineHelper::getDoctrineType('BOOLEAN'): + $convertedValue = $type->convertToPHPValue($value, $platform); + + break; + default: + $convertedValue = $type->convertToDatabaseValue($value, $platform); + } + + return $convertedValue; + } + + /** + * Computes a usable diff. + * + * @param object $entity + * + * @throws \Doctrine\DBAL\DBALException + * @throws \Doctrine\ORM\Mapping\MappingException + */ + private function diff(EntityManagerInterface $em, $entity, array $ch): array + { + /** @var ClassMetadata $meta */ + $meta = $em->getClassMetadata(DoctrineHelper::getRealClassName($entity)); + $diff = []; + + foreach ($ch as $fieldName => [$old, $new]) { + $o = null; + $n = null; + + if ( + $meta->hasField($fieldName) && + !isset($meta->embeddedClasses[$fieldName]) && + $this->configuration->isAuditedField($entity, $fieldName) + ) { + $mapping = $meta->fieldMappings[$fieldName]; + $type = Type::getType($mapping['type']); + $o = $this->value($em, $type, $old); + $n = $this->value($em, $type, $new); + } elseif ( + $meta->hasAssociation($fieldName) && + $meta->isSingleValuedAssociation($fieldName) && + $this->configuration->isAuditedField($entity, $fieldName) + ) { + $o = $this->summarize($em, $old); + $n = $this->summarize($em, $new); + } + + if ($o !== $n) { + $diff[$fieldName] = [ + 'old' => $o, + 'new' => $n, + ]; + } + } + ksort($diff); + + return $diff; + } + + /** + * Returns an array describing an entity. + * + * @param object $entity + * @param mixed $id + * + * @throws \Doctrine\DBAL\DBALException + * @throws \Doctrine\ORM\Mapping\MappingException + * + * @return array + */ + private function summarize(EntityManagerInterface $em, $entity = null, $id = null): ?array + { + if (null === $entity) { + return null; + } + + $em->getUnitOfWork()->initializeObject($entity); // ensure that proxies are initialized + /** @var ClassMetadata $meta */ + $meta = $em->getClassMetadata(DoctrineHelper::getRealClassName($entity)); + $pkName = $meta->getSingleIdentifierFieldName(); + $pkValue = $id ?? $this->id($em, $entity); + // An added guard for proxies that fail to initialize. + if (null === $pkValue) { + return null; + } + + if (method_exists($entity, '__toString')) { + $label = (string) $entity; + } else { + $label = DoctrineHelper::getRealClassName($entity).'#'.$pkValue; + } + + return [ + 'label' => $label, + 'class' => $meta->name, + 'table' => $meta->getTableName(), + $pkName => $pkValue, + ]; + } + + /** + * Blames an audit operation. + */ + private function blame(): array + { + $user_id = null; + $username = null; + $client_ip = null; + $user_fqdn = null; + $user_firewall = null; + + $request = $this->configuration->getRequestStack()->getCurrentRequest(); + if (null !== $request) { + $client_ip = $request->getClientIp(); + $user_firewall = null === $this->configuration->getFirewallMap()->getFirewallConfig($request) ? null : $this->configuration->getFirewallMap()->getFirewallConfig($request)->getName(); + } + + $user = null === $this->configuration->getUserProvider() ? null : $this->configuration->getUserProvider()->getUser(); + if ($user instanceof UserInterface) { + $user_id = $user->getId(); + $username = $user->getUsername(); + $user_fqdn = DoctrineHelper::getRealClassName($user); + } + + return [ + 'user_id' => $user_id, + 'username' => $username, + 'client_ip' => $client_ip, + 'user_fqdn' => $user_fqdn, + 'user_firewall' => $user_firewall, + ]; + } +} diff --git a/src/Provider/Doctrine/Transaction/TransactionHydrator.php b/src/Provider/Doctrine/Transaction/TransactionHydrator.php new file mode 100644 index 00000000..4dc9b210 --- /dev/null +++ b/src/Provider/Doctrine/Transaction/TransactionHydrator.php @@ -0,0 +1,136 @@ +configuration = $configuration; + $this->em = $this->configuration->getEntityManager(); + } + + public function hydrate(Transaction $transaction): void + { + $uow = $this->em->getUnitOfWork(); + + $this->hydrateWithScheduledInsertions($transaction, $uow); + $this->hydrateWithScheduledUpdates($transaction, $uow); + $this->hydrateWithScheduledDeletions($transaction, $uow, $this->em); + $this->hydrateWithScheduledCollectionUpdates($transaction, $uow, $this->em); + $this->hydrateWithScheduledCollectionDeletions($transaction, $uow, $this->em); + } + + private function hydrateWithScheduledInsertions(Transaction $transaction, UnitOfWork $uow): void + { + foreach ($uow->getScheduledEntityInsertions() as $entity) { + if ($this->configuration->isAudited($entity)) { + $transaction->trackAuditEvent(Transaction::INSERT, [ + $entity, + $uow->getEntityChangeSet($entity), + ]); + } + } + } + + private function hydrateWithScheduledUpdates(Transaction $transaction, UnitOfWork $uow): void + { + foreach ($uow->getScheduledEntityUpdates() as $entity) { + if ($this->configuration->isAudited($entity)) { + $transaction->trackAuditEvent(Transaction::UPDATE, [ + $entity, + $uow->getEntityChangeSet($entity), + ]); + } + } + } + + /** + * @throws \Doctrine\DBAL\DBALException + * @throws \Doctrine\ORM\Mapping\MappingException + */ + private function hydrateWithScheduledDeletions(Transaction $transaction, UnitOfWork $uow, EntityManagerInterface $em): void + { + foreach ($uow->getScheduledEntityDeletions() as $entity) { + if ($this->configuration->isAudited($entity)) { + $uow->initializeObject($entity); + $transaction->trackAuditEvent(Transaction::REMOVE, [ + $entity, + $this->id($em, $entity), + ]); + } + } + } + + /** + * @throws \Doctrine\DBAL\DBALException + * @throws \Doctrine\ORM\Mapping\MappingException + */ + private function hydrateWithScheduledCollectionUpdates(Transaction $transaction, UnitOfWork $uow, EntityManagerInterface $em): void + { + foreach ($uow->getScheduledCollectionUpdates() as $collection) { + if ($this->configuration->isAudited($collection->getOwner())) { + $mapping = $collection->getMapping(); + foreach ($collection->getInsertDiff() as $entity) { + if ($this->configuration->isAudited($entity)) { + $transaction->trackAuditEvent(Transaction::ASSOCIATE, [ + $collection->getOwner(), + $entity, + $mapping, + ]); + } + } + foreach ($collection->getDeleteDiff() as $entity) { + if ($this->configuration->isAudited($entity)) { + $transaction->trackAuditEvent(Transaction::DISSOCIATE, [ + $collection->getOwner(), + $entity, + $this->id($em, $entity), + $mapping, + ]); + } + } + } + } + } + + /** + * @throws \Doctrine\DBAL\DBALException + * @throws \Doctrine\ORM\Mapping\MappingException + */ + private function hydrateWithScheduledCollectionDeletions(Transaction $transaction, UnitOfWork $uow, EntityManagerInterface $em): void + { + foreach ($uow->getScheduledCollectionDeletions() as $collection) { + if ($this->configuration->isAudited($collection->getOwner())) { + $mapping = $collection->getMapping(); + foreach ($collection->toArray() as $entity) { + if ($this->configuration->isAudited($entity)) { + $transaction->trackAuditEvent(Transaction::DISSOCIATE, [ + $collection->getOwner(), + $entity, + $this->id($em, $entity), + $mapping, + ]); + } + } + } + } + } +} diff --git a/src/Provider/Doctrine/Transaction/TransactionProcessor.php b/src/Provider/Doctrine/Transaction/TransactionProcessor.php new file mode 100644 index 00000000..db28b16c --- /dev/null +++ b/src/Provider/Doctrine/Transaction/TransactionProcessor.php @@ -0,0 +1,303 @@ +configuration = $configuration; + $this->em = $this->configuration->getEntityManager(); + } + + /** + * @throws \Doctrine\DBAL\DBALException + * @throws \Doctrine\ORM\Mapping\MappingException + */ + public function process(Transaction $transaction): void + { + $this->processInsertions($transaction); + $this->processUpdates($transaction); + $this->processAssociations($transaction); + $this->processDissociations($transaction); + $this->processDeletions($transaction); + } + + private function notify(array $payload): void + { + $dispatcher = $this->configuration->getEventDispatcher(); + + if ($this->configuration->isPre43Dispatcher()) { + // Symfony 3.x + $dispatcher->dispatch(LifecycleEvent::class, new LifecycleEvent($payload)); + } else { + // Symfony 4.x + $dispatcher->dispatch(new LifecycleEvent($payload)); + } + } + + /** + * Adds an insert entry to the audit table. + * + * @param object $entity + * + * @throws \Doctrine\DBAL\DBALException + * @throws \Doctrine\ORM\Mapping\MappingException + */ + private function insert(EntityManagerInterface $em, $entity, array $ch, string $transactionHash): void + { + /** @var ClassMetadata $meta */ + $meta = $em->getClassMetadata(DoctrineHelper::getRealClassName($entity)); + $this->audit([ + 'action' => 'insert', + 'blame' => $this->blame(), + 'diff' => $this->diff($em, $entity, $ch), + 'table' => $meta->getTableName(), + 'schema' => $meta->getSchemaName(), + 'id' => $this->id($em, $entity), + 'transaction_hash' => $transactionHash, + 'discriminator' => $this->getDiscriminator($entity, $meta->inheritanceType), + 'entity' => $meta->getName(), + ]); + } + + /** + * Adds an update entry to the audit table. + * + * @param object $entity + * + * @throws \Doctrine\DBAL\DBALException + * @throws \Doctrine\ORM\Mapping\MappingException + */ + private function update(EntityManagerInterface $em, $entity, array $ch, string $transactionHash): void + { + $diff = $this->diff($em, $entity, $ch); + if (0 === \count($diff)) { + return; // if there is no entity diff, do not log it + } + /** @var ClassMetadata $meta */ + $meta = $em->getClassMetadata(DoctrineHelper::getRealClassName($entity)); + $this->audit([ + 'action' => 'update', + 'blame' => $this->blame(), + 'diff' => $diff, + 'table' => $meta->getTableName(), + 'schema' => $meta->getSchemaName(), + 'id' => $this->id($em, $entity), + 'transaction_hash' => $transactionHash, + 'discriminator' => $this->getDiscriminator($entity, $meta->inheritanceType), + 'entity' => $meta->getName(), + ]); + } + + /** + * Adds a remove entry to the audit table. + * + * @param object $entity + * @param mixed $id + * + * @throws \Doctrine\DBAL\DBALException + * @throws \Doctrine\ORM\Mapping\MappingException + */ + private function remove(EntityManagerInterface $em, $entity, $id, string $transactionHash): void + { + /** @var ClassMetadata $meta */ + $meta = $em->getClassMetadata(DoctrineHelper::getRealClassName($entity)); + $this->audit([ + 'action' => 'remove', + 'blame' => $this->blame(), + 'diff' => $this->summarize($em, $entity, $id), + 'table' => $meta->getTableName(), + 'schema' => $meta->getSchemaName(), + 'id' => $id, + 'transaction_hash' => $transactionHash, + 'discriminator' => $this->getDiscriminator($entity, $meta->inheritanceType), + 'entity' => $meta->getName(), + ]); + } + + /** + * Adds an association entry to the audit table. + * + * @param object $source + * @param object $target + * + * @throws \Doctrine\DBAL\DBALException + * @throws \Doctrine\ORM\Mapping\MappingException + */ + private function associate(EntityManagerInterface $em, $source, $target, array $mapping, string $transactionHash): void + { + $this->associateOrDissociate('associate', $em, $source, $target, $mapping, $transactionHash); + } + + /** + * Adds a dissociation entry to the audit table. + * + * @param object $source + * @param object $target + * + * @throws \Doctrine\DBAL\DBALException + * @throws \Doctrine\ORM\Mapping\MappingException + */ + private function dissociate(EntityManagerInterface $em, $source, $target, array $mapping, string $transactionHash): void + { + $this->associateOrDissociate('dissociate', $em, $source, $target, $mapping, $transactionHash); + } + + /** + * @throws \Doctrine\DBAL\DBALException + * @throws \Doctrine\ORM\Mapping\MappingException + */ + private function processInsertions(Transaction $transaction): void + { + $uow = $this->em->getUnitOfWork(); + foreach ($transaction->getInserted() as [$entity, $ch]) { + // the changeset might be updated from UOW extra updates + $ch = array_merge($ch, $uow->getEntityChangeSet($entity)); + $this->insert($this->em, $entity, $ch, $transaction->getTransactionHash()); + } + } + + /** + * @throws \Doctrine\DBAL\DBALException + * @throws \Doctrine\ORM\Mapping\MappingException + */ + private function processUpdates(Transaction $transaction): void + { + $uow = $this->em->getUnitOfWork(); + foreach ($transaction->getUpdated() as [$entity, $ch]) { + // the changeset might be updated from UOW extra updates + $ch = array_merge($ch, $uow->getEntityChangeSet($entity)); + $this->update($this->em, $entity, $ch, $transaction->getTransactionHash()); + } + } + + /** + * @throws \Doctrine\DBAL\DBALException + * @throws \Doctrine\ORM\Mapping\MappingException + */ + private function processAssociations(Transaction $transaction): void + { + foreach ($transaction->getAssociated() as [$source, $target, $mapping]) { + $this->associate($this->em, $source, $target, $mapping, $transaction->getTransactionHash()); + } + } + + /** + * @throws \Doctrine\DBAL\DBALException + * @throws \Doctrine\ORM\Mapping\MappingException + */ + private function processDissociations(Transaction $transaction): void + { + foreach ($transaction->getDissociated() as [$source, $target, $id, $mapping]) { + $this->dissociate($this->em, $source, $target, $mapping, $transaction->getTransactionHash()); + } + } + + /** + * @throws \Doctrine\DBAL\DBALException + * @throws \Doctrine\ORM\Mapping\MappingException + */ + private function processDeletions(Transaction $transaction): void + { + foreach ($transaction->getRemoved() as [$entity, $id]) { + $this->remove($this->em, $entity, $id, $transaction->getTransactionHash()); + } + } + + /** + * Adds an association entry to the audit table. + * + * @param object $source + * @param object $target + * + * @throws \Doctrine\DBAL\DBALException + * @throws \Doctrine\ORM\Mapping\MappingException + */ + private function associateOrDissociate(string $type, EntityManagerInterface $em, $source, $target, array $mapping, string $transactionHash): void + { + /** @var ClassMetadata $meta */ + $meta = $em->getClassMetadata(DoctrineHelper::getRealClassName($source)); + $data = [ + 'action' => $type, + 'blame' => $this->blame(), + 'diff' => [ + 'source' => $this->summarize($em, $source), + 'target' => $this->summarize($em, $target), + ], + 'table' => $meta->getTableName(), + 'schema' => $meta->getSchemaName(), + 'id' => $this->id($em, $source), + 'transaction_hash' => $transactionHash, + 'discriminator' => $this->getDiscriminator($source, $meta->inheritanceType), + 'entity' => $meta->getName(), + ]; + + if (isset($mapping['joinTable']['name'])) { + $data['diff']['table'] = $mapping['joinTable']['name']; + } + + $this->audit($data); + } + + /** + * Adds an entry to the audit table. + * + * @throws Exception + */ + private function audit(array $data): void + { + $schema = $data['schema'] ? $data['schema'].'.' : ''; + $auditTable = $schema.$this->configuration->getTablePrefix().$data['table'].$this->configuration->getTableSuffix(); + $dt = new DateTime('now', new DateTimeZone($this->configuration->getTimezone())); + + $payload = [ + 'entity' => $data['entity'], + 'table' => $auditTable, + 'type' => $data['action'], + 'object_id' => (string) $data['id'], + 'discriminator' => $data['discriminator'], + 'transaction_hash' => (string) $data['transaction_hash'], + 'diffs' => json_encode($data['diff']), + 'blame_id' => $data['blame']['user_id'], + 'blame_user' => $data['blame']['username'], + 'blame_user_fqdn' => $data['blame']['user_fqdn'], + 'blame_user_firewall' => $data['blame']['user_firewall'], + 'ip' => $data['blame']['client_ip'], + 'created_at' => $dt->format('Y-m-d H:i:s'), + ]; + + // send an `AuditEvent` event + $this->notify($payload); + } + + /** + * @param object $entity + */ + private function getDiscriminator($entity, int $inheritanceType): ?string + { + return ClassMetadata::INHERITANCE_TYPE_SINGLE_TABLE === $inheritanceType ? DoctrineHelper::getRealClassName($entity) : null; + } +} diff --git a/src/Provider/Doctrine/Updater/UpdateManager.php b/src/Provider/Doctrine/Updater/UpdateManager.php new file mode 100644 index 00000000..7ea7ca5a --- /dev/null +++ b/src/Provider/Doctrine/Updater/UpdateManager.php @@ -0,0 +1,217 @@ +transactionManager = $transactionManager; + $this->reader = $reader; + } + + public function getConfiguration(): Configuration + { + return $this->transactionManager->getConfiguration(); + } + + /** + * @param null|array $sqls SQL queries to execute + * @param null|callable $callback Callback executed after each query is run + */ + public function updateAuditSchema(?array $sqls = null, ?callable $callback = null): void + { + $auditEntityManager = $this->transactionManager->getConfiguration()->getEntityManager(); + + if (null === $sqls) { + $sqls = $this->getUpdateAuditSchemaSql(); + } + + foreach ($sqls as $index => $sql) { + try { + $statement = $auditEntityManager->getConnection()->prepare($sql); + $statement->execute(); + + if (null !== $callback) { + $callback([ + 'total' => \count($sqls), + 'current' => $index, + ]); + } + } catch (Exception $e) { + // something bad happened here :/ + } + } + } + + public function getUpdateAuditSchemaSql(): array + { + $readerEntityManager = $this->reader->getEntityManager(); + $readerSchemaManager = $readerEntityManager->getConnection()->getSchemaManager(); + + $auditEntityManager = $this->transactionManager->getConfiguration()->getEntityManager(); + $auditSchemaManager = $auditEntityManager->getConnection()->getSchemaManager(); + + $auditSchema = $auditSchemaManager->createSchema(); + $fromSchema = clone $auditSchema; + $readerSchema = $readerSchemaManager->createSchema(); + $tables = $readerSchema->getTables(); + + $entities = $this->reader->getEntities(); + foreach ($tables as $table) { + if (\in_array($table->getName(), array_values($entities), true)) { + $auditTablename = preg_replace( + sprintf('#^([^\.]+\.)?(%s)$#', preg_quote($table->getName(), '#')), + sprintf( + '$1%s$2%s', + preg_quote($this->transactionManager->getConfiguration()->getTablePrefix(), '#'), + preg_quote($this->transactionManager->getConfiguration()->getTableSuffix(), '#') + ), + $table->getName() + ); + + if ($auditSchema->hasTable($auditTablename)) { + $this->updateAuditTable($auditSchema->getTable($auditTablename), $auditSchema); + } else { + $this->createAuditTable($table, $auditSchema); + } + } + } + + return $fromSchema->getMigrateToSql($auditSchema, $auditSchemaManager->getDatabasePlatform()); + } + + /** + * Creates an audit table. + * + * @throws \Doctrine\DBAL\DBALException + */ + public function createAuditTable(Table $table, ?Schema $schema = null): Schema + { + $entityManager = $this->getConfiguration()->getEntityManager(); + $schemaManager = $entityManager->getConnection()->getSchemaManager(); + if (null === $schema) { + $schema = $schemaManager->createSchema(); + } + + $auditTablename = preg_replace( + sprintf('#^([^\.]+\.)?(%s)$#', preg_quote($table->getName(), '#')), + sprintf( + '$1%s$2%s', + preg_quote($this->getConfiguration()->getTablePrefix(), '#'), + preg_quote($this->getConfiguration()->getTableSuffix(), '#') + ), + $table->getName() + ); + + if (null !== $auditTablename && !$schema->hasTable($auditTablename)) { + $auditTable = $schema->createTable($auditTablename); + + // Add columns to audit table + foreach (SchemaHelper::getAuditTableColumns() as $columnName => $struct) { + $auditTable->addColumn($columnName, $struct['type'], $struct['options']); + } + + // Add indices to audit table + foreach (SchemaHelper::getAuditTableIndices($auditTablename) as $columnName => $struct) { + if ('primary' === $struct['type']) { + $auditTable->setPrimaryKey([$columnName]); + } else { + $auditTable->addIndex([$columnName], $struct['name']); + } + } + } + + return $schema; + } + + /** + * Ensures an audit table's structure is valid. + * + * @throws UpdateException + * @throws \Doctrine\DBAL\Schema\SchemaException + */ + public function updateAuditTable(Table $table, ?Schema $schema = null, ?array $expectedColumns = null, ?array $expectedIndices = null): Schema + { + $entityManager = $this->getConfiguration()->getEntityManager(); + $schemaManager = $entityManager->getConnection()->getSchemaManager(); + if (null === $schema) { + $schema = $schemaManager->createSchema(); + } + + $table = $schema->getTable($table->getName()); + + $columns = $schemaManager->listTableColumns($table->getName()); + + // process columns + $this->processColumns($table, $columns, $expectedColumns ?? SchemaHelper::getAuditTableColumns()); + + // process indices + $this->processIndices($table, $expectedIndices ?? SchemaHelper::getAuditTableIndices($table->getName())); + + return $schema; + } + + private function processColumns(Table $table, array $columns, array $expectedColumns): void + { + $processed = []; + + foreach ($columns as $column) { + if (\array_key_exists($column->getName(), $expectedColumns)) { + // column is part of expected columns + $table->dropColumn($column->getName()); + $table->addColumn($column->getName(), $expectedColumns[$column->getName()]['type'], $expectedColumns[$column->getName()]['options']); + } else { + // column is not part of expected columns so it has to be removed + $table->dropColumn($column->getName()); + } + + $processed[] = $column->getName(); + } + + foreach ($expectedColumns as $columnName => $options) { + if (!\in_array($columnName, $processed, true)) { + // expected column in not part of concrete ones so it's a new column, we need to add it + $table->addColumn($columnName, $options['type'], $options['options']); + } + } + } + + /** + * @throws \Doctrine\DBAL\Schema\SchemaException + */ + private function processIndices(Table $table, array $expectedIndices): void + { + foreach ($expectedIndices as $columnName => $options) { + if ('primary' === $options['type']) { + $table->dropPrimaryKey(); + $table->setPrimaryKey([$columnName]); + } else { + if ($table->hasIndex($options['name'])) { + $table->dropIndex($options['name']); + } + $table->addIndex([$columnName], $options['name']); + } + } + } +} diff --git a/src/Provider/ProviderInterface.php b/src/Provider/ProviderInterface.php new file mode 100644 index 00000000..38d2f7a2 --- /dev/null +++ b/src/Provider/ProviderInterface.php @@ -0,0 +1,13 @@ +createAuditor(); + + self::assertInstanceOf(Configuration::class, $auditor->getConfiguration()); + } + + public function testGetProvider(): void + { + $auditor = $this->createAuditor(); + + self::assertInstanceOf(ProviderInterface::class, $auditor->getProvider()); + self::assertInstanceOf(DummyProvider::class, $auditor->getProvider()); + } + + public function testGetEventDispatcher(): void + { + $auditor = $this->createAuditor(); + + self::assertInstanceOf(EventDispatcher::class, $auditor->getEventDispatcher(), 'Auditor::getEventDispatcher() is OK.'); + } + + public function testIsPre43Dispatcher(): void + { + $auditor = $this->createAuditor(); + + $r = new ReflectionMethod($auditor->getEventDispatcher(), 'dispatch'); + $p = $r->getParameters(); + $isPre43Dispatcher = 2 === \count($p) && 'event' !== $p[0]->name; + + self::assertSame($isPre43Dispatcher, $auditor->isPre43Dispatcher(), 'Auditor::isPre43Dispatcher() is OK.'); + } +} diff --git a/tests/ConfigurationTest.php b/tests/ConfigurationTest.php new file mode 100644 index 00000000..cdc03ba0 --- /dev/null +++ b/tests/ConfigurationTest.php @@ -0,0 +1,54 @@ +createAuditorConfiguration(); + + self::assertTrue($configuration->isEnabled(), 'Auditor is enabled by default.'); + } + + public function testDisable(): void + { + $configuration = $this->createAuditorConfiguration(); + $configuration->disable(); + + self::assertFalse($configuration->isEnabled(), 'Auditor is disabled.'); + } + + public function testEnable(): void + { + $configuration = $this->createAuditorConfiguration(); + $configuration->disable(); + $configuration->enable(); + + self::assertTrue($configuration->isEnabled(), 'Auditor is enabled.'); + } + + public function testDefaultTimezoneIsUTC(): void + { + $configuration = $this->createAuditorConfiguration(); + + self::assertSame('UTC', $configuration->getTimezone(), 'Default timezone is UTC.'); + } + + public function testCustomTimezone(): void + { + $configuration = $this->createAuditorConfiguration([ + 'timezone' => 'Europe/Paris', + ]); + + self::assertSame('Europe/Paris', $configuration->getTimezone(), 'Custom timezone is "Europe/Paris".'); + } +} diff --git a/tests/EventSubscriber/AuditEventSubscriberTest.php b/tests/EventSubscriber/AuditEventSubscriberTest.php new file mode 100644 index 00000000..2cc0d3b8 --- /dev/null +++ b/tests/EventSubscriber/AuditEventSubscriberTest.php @@ -0,0 +1,27 @@ +createAuditor(); + $dispatcher = $auditor->getEventDispatcher(); + $subscriber = new AuditEventSubscriber($auditor); + $dispatcher->addSubscriber($subscriber); + $dispatcher->dispatch(new LifecycleEvent(['fake payload'])); + + self::assertArrayHasKey(LifecycleEvent::class, AuditEventSubscriber::getSubscribedEvents()); + } +} diff --git a/tests/Fixtures/DummyProvider.php b/tests/Fixtures/DummyProvider.php new file mode 100644 index 00000000..833b7aec --- /dev/null +++ b/tests/Fixtures/DummyProvider.php @@ -0,0 +1,14 @@ +createProviderConfiguration(); + + self::assertSame('', $configuration->getTablePrefix(), '"table_prefix" is empty by default.'); + } + + public function testDefaultTableSuffix(): void + { + $configuration = $this->createProviderConfiguration(); + + self::assertSame('_audit', $configuration->getTableSuffix(), '"table_suffix" is "_audit" by default.'); + } + + public function testCustomTablePrefix(): void + { + $configuration = $this->createProviderConfiguration([ + 'table_prefix' => 'audit_', + ]); + + self::assertSame('audit_', $configuration->getTablePrefix(), 'Custom "table_prefix" is "audit_".'); + } + + public function testCustomTableSuffix(): void + { + $configuration = $this->createProviderConfiguration([ + 'table_suffix' => '_audit_log', + ]); + + self::assertSame('_audit_log', $configuration->getTableSuffix(), 'Custom "table_suffix" is "_audit_log".'); + } + + public function testIsEnabledViewerDefault(): void + { + $configuration = $this->createProviderConfiguration(); + + self::assertTrue($configuration->isEnabledViewer(), 'Viewer is enabled by default.'); + } + + public function testDisableViewer(): void + { + $configuration = $this->createProviderConfiguration(); + $configuration->disableViewer(); + + self::assertFalse($configuration->isEnabledViewer(), 'Viewer is disabled.'); + } + + public function testEnableViewer(): void + { + $configuration = $this->createProviderConfiguration(); + $configuration->disableViewer(); + $configuration->enableViewer(); + + self::assertTrue($configuration->isEnabledViewer(), 'Viewer is enabled.'); + } + + public function testGloballyIgnoredColumns(): void + { + $ignored = [ + 'created_at', + 'updated_at', + ]; + + $configuration = $this->createProviderConfiguration([ + 'ignored_columns' => $ignored, + ]); + + self::assertSame($ignored, $configuration->getIgnoredColumns(), '"ignored_columns" are honored.'); + } + + public function testGetEntities(): void + { + $entities = [ + Post::class => null, + Comment::class => null, + AuditedEntity::class => [ + 'ignored_columns' => ['ignoredField'], + 'enabled' => true, + 'roles' => null, + ], + UnauditedEntity::class => [ + 'ignored_columns' => ['ignoredField'], + 'enabled' => false, + 'roles' => [ + Security::VIEW_SCOPE => ['ROLE1', 'ROLE2'], + ], + ], + ]; + + $configuration = $this->createProviderConfiguration([ + 'entities' => $entities, + ]); + + self::assertSame($entities, $configuration->getEntities(), 'AuditConfiguration::getEntities() returns configured entities list.'); + } +} diff --git a/tests/Provider/Doctrine/DoctrineProviderTest.php b/tests/Provider/Doctrine/DoctrineProviderTest.php new file mode 100644 index 00000000..fe509f62 --- /dev/null +++ b/tests/Provider/Doctrine/DoctrineProviderTest.php @@ -0,0 +1,316 @@ + null, + ]; + + $configuration = $this->createProviderConfiguration([ + 'entities' => $entities, + ]); + $provider = $this->createDoctrineProvider($configuration); + + self::assertTrue($provider->isAudited(Post::class), 'Entity "'.Post::class.'" is audited.'); + self::assertFalse($provider->isAudited(Comment::class), 'Entity "'.Comment::class.'" is not audited.'); + } + + public function testIsAuditable(): void + { + $entities = [ + Post::class => [ + 'enabled' => false, + ], + ]; + + $configuration = $this->createProviderConfiguration([ + 'entities' => $entities, + ]); + $provider = $this->createDoctrineProvider($configuration); + + self::assertFalse($provider->isAudited(Post::class), 'Entity "'.Post::class.'" is not audited.'); + self::assertTrue($provider->isAuditable(Post::class), 'Entity "'.Post::class.'" is auditable.'); + self::assertFalse($provider->isAudited(Comment::class), 'Entity "'.Comment::class.'" is not audited.'); + self::assertFalse($provider->isAuditable(Comment::class), 'Entity "'.Comment::class.'" is not auditable.'); + } + + /** + * @depends testIsAudited + */ + public function testIsAuditedHonorsEnabledFlag(): void + { + $entities = [ + Post::class => [ + 'enabled' => true, + ], + ]; + + $configuration = $this->createProviderConfiguration([ + 'entities' => $entities, + ]); + $provider = $this->createDoctrineProvider($configuration); + + self::assertTrue($provider->isAudited(Post::class), 'Entity "'.Post::class.'" is audited.'); + + $entities = [ + Post::class => [ + 'enabled' => false, + ], + ]; + + $configuration = $this->createProviderConfiguration([ + 'entities' => $entities, + ]); + $provider = $this->createDoctrineProvider($configuration); + + self::assertFalse($provider->isAudited(Post::class), 'Entity "'.Post::class.'" is not audited.'); + } + + /** + * @depends testIsAudited + */ + public function testIsAuditedWhenAuditIsEnabled(): void + { + $entities = [ + Post::class => [ + 'enabled' => true, + ], + ]; + + $configuration = $this->createProviderConfiguration([ + 'entities' => $entities, + ]); + $provider = $this->createDoctrineProvider($configuration); + $provider->getAuditor()->getConfiguration()->enable(); + + self::assertTrue($provider->isAudited(Post::class), 'Entity "'.Post::class.'" is audited.'); + + $entities = [ + Post::class => [ + 'enabled' => false, + ], + ]; + + $configuration = $this->createProviderConfiguration([ + 'entities' => $entities, + ]); + $provider = $this->createDoctrineProvider($configuration); + $provider->getAuditor()->getConfiguration()->enable(); + + self::assertFalse($provider->isAudited(Post::class), 'Entity "'.Post::class.'" is not audited.'); + } + + /** + * @depends testIsAudited + */ + public function testIsAuditedWhenAuditIsDisabled(): void + { + $entities = [ + Post::class => [ + 'enabled' => true, + ], + ]; + + $configuration = $this->createProviderConfiguration([ + 'entities' => $entities, + ]); + $provider = $this->createDoctrineProvider($configuration); + + self::assertTrue($provider->isAudited(Post::class), 'Entity "'.Post::class.'" is audited.'); + + $provider->getAuditor()->getConfiguration()->disable(); + + self::assertFalse($provider->isAudited(Post::class), 'Entity "'.Post::class.'" is not audited.'); + } + + /** + * @depends testIsAudited + */ + public function testIsAuditedFieldAuditsAnyFieldByDefault(): void + { + $entities = [ + Post::class => null, + ]; + + $configuration = $this->createProviderConfiguration([ + 'entities' => $entities, + ]); + $provider = $this->createDoctrineProvider($configuration); + + self::assertTrue($provider->isAuditedField(Post::class, 'id'), 'Every field is audited.'); + self::assertTrue($provider->isAuditedField(Post::class, 'title'), 'Every field is audited.'); + self::assertTrue($provider->isAuditedField(Post::class, 'created_at'), 'Every field is audited.'); + self::assertTrue($provider->isAuditedField(Post::class, 'updated_at'), 'Every field is audited.'); + } + + /** + * @depends testIsAuditedFieldAuditsAnyFieldByDefault + */ + public function testIsAuditedFieldHonorsLocallyIgnoredColumns(): void + { + $entities = [ + Post::class => [ + 'ignored_columns' => [ + 'created_at', + 'updated_at', + ], + ], + ]; + + $configuration = $this->createProviderConfiguration([ + 'entities' => $entities, + ]); + $provider = $this->createDoctrineProvider($configuration); + + self::assertTrue($provider->isAuditedField(Post::class, 'id'), 'Field "'.Post::class.'::$id" is audited.'); + self::assertTrue($provider->isAuditedField(Post::class, 'title'), 'Field "'.Post::class.'::$title" is audited.'); + self::assertFalse($provider->isAuditedField(Post::class, 'created_at'), 'Field "'.Post::class.'::$created_at" is not audited.'); + self::assertFalse($provider->isAuditedField(Post::class, 'updated_at'), 'Field "'.Post::class.'::$updated_at" is not audited.'); + } + + /** + * @depends testIsAuditedFieldHonorsLocallyIgnoredColumns + */ + public function testIsAuditedFieldHonorsGloballyIgnoredColumns(): void + { + $entities = [ + Post::class => null, + ]; + + $configuration = $this->createProviderConfiguration([ + 'ignored_columns' => [ + 'created_at', + 'updated_at', + ], + 'entities' => $entities, + ]); + $provider = $this->createDoctrineProvider($configuration); + + self::assertTrue($provider->isAuditedField(Post::class, 'id'), 'Field "'.Post::class.'::$id" is audited.'); + self::assertTrue($provider->isAuditedField(Post::class, 'title'), 'Field "'.Post::class.'::$title" is audited.'); + self::assertFalse($provider->isAuditedField(Post::class, 'created_at'), 'Field "'.Post::class.'::$created_at" is not audited.'); + self::assertFalse($provider->isAuditedField(Post::class, 'updated_at'), 'Field "'.Post::class.'::$updated_at" is not audited.'); + } + + /** + * @depends testIsAuditedFieldHonorsLocallyIgnoredColumns + */ + public function testIsAuditedFieldReturnsFalseIfEntityIsNotAudited(): void + { + $entities = [ + Post::class => null, + ]; + + $configuration = $this->createProviderConfiguration([ + 'ignored_columns' => [ + 'created_at', + 'updated_at', + ], + 'entities' => $entities, + ]); + $provider = $this->createDoctrineProvider($configuration); + + self::assertFalse($provider->isAuditedField(Comment::class, 'id'), 'Field "'.Comment::class.'::$id" is audited but "'.Comment::class.'" entity is not.'); + } + + /** + * @depends testIsAuditedHonorsEnabledFlag + */ + public function testEnableAuditFor(): void + { + $entities = [ + Post::class => [ + 'enabled' => false, + ], + ]; + + $configuration = $this->createProviderConfiguration([ + 'entities' => $entities, + ]); + $provider = $this->createDoctrineProvider($configuration); + + self::assertFalse($provider->isAudited(Post::class), 'entity "'.Post::class.'" is not audited.'); + + $configuration->enableAuditFor(Post::class); + + self::assertTrue($provider->isAudited(Post::class), 'entity "'.Post::class.'" is audited.'); + } + + /** + * @depends testIsAuditedHonorsEnabledFlag + */ + public function testDisableAuditFor(): void + { + $entities = [ + Post::class => [ + 'enabled' => true, + ], + ]; + + $configuration = $this->createProviderConfiguration([ + 'entities' => $entities, + ]); + $provider = $this->createDoctrineProvider($configuration); + + self::assertTrue($provider->isAudited(Post::class), 'entity "'.Post::class.'" is audited.'); + + $configuration->disableAuditFor(Post::class); + + self::assertFalse($provider->isAudited(Post::class), 'entity "'.Post::class.'" is not audited.'); + } + + public function testSetEntities(): void + { + $configuration = $this->createProviderConfiguration([ + 'entities' => [Tag::class => null], + ]); + $entities1 = $configuration->getEntities(); + + $entities = [ + Post::class => null, + Comment::class => null, + ]; + + $configuration->setEntities($entities); + $entities2 = $configuration->getEntities(); + + self::assertNotSame($entities2, $entities1, 'Configuration::setEntities() replaces previously configured entities.'); + } + + public function testGetAnnotationReader(): void + { + $entities = [ + Post::class => [ + 'enabled' => true, + ], + ]; + + $configuration = $this->createProviderConfiguration([ + 'entities' => $entities, + ]); + $provider = $this->createDoctrineProvider($configuration); + + self::assertInstanceOf(AnnotationLoader::class, $provider->getAnnotationLoader(), 'AnnotationLoader is set.'); + } + + public function testPersist(): void + { + self::markTestIncomplete('Not yet implemented.'); + } +} diff --git a/tests/Provider/Doctrine/Fixtures/Entity/Annotation/AuditedEntity.php b/tests/Provider/Doctrine/Fixtures/Entity/Annotation/AuditedEntity.php new file mode 100644 index 00000000..17740aa3 --- /dev/null +++ b/tests/Provider/Doctrine/Fixtures/Entity/Annotation/AuditedEntity.php @@ -0,0 +1,33 @@ +id; + } + + final public function getLabel() + { + return $this->label; + } + + final public function setLabel($label) + { + $this->label = $label; + + return $this; + } +} diff --git a/tests/Provider/Doctrine/Fixtures/Entity/Inheritance/Bike.php b/tests/Provider/Doctrine/Fixtures/Entity/Inheritance/Bike.php new file mode 100644 index 00000000..ab8e7fbf --- /dev/null +++ b/tests/Provider/Doctrine/Fixtures/Entity/Inheritance/Bike.php @@ -0,0 +1,12 @@ +wheels; + } + + public function setWheels(int $wheels): self + { + $this->wheels = $wheels; + + return $this; + } + + public function getId() + { + return $this->id; + } + + public function getLabel() + { + return $this->label; + } + + public function setLabel($label) + { + $this->label = $label; + + return $this; + } +} diff --git a/tests/Provider/Doctrine/Fixtures/Entity/Standard/Author.php b/tests/Provider/Doctrine/Fixtures/Entity/Standard/Author.php new file mode 100644 index 00000000..389d44f6 --- /dev/null +++ b/tests/Provider/Doctrine/Fixtures/Entity/Standard/Author.php @@ -0,0 +1,146 @@ +posts = new ArrayCollection(); + } + + public function __sleep() + { + return ['id', 'fullname', 'email']; + } + + /** + * Set the value of id. + * + * @return Author + */ + public function setId(int $id): self + { + $this->id = $id; + + return $this; + } + + /** + * Get the value of id. + * + * @return int + */ + public function getId(): ?int + { + return $this->id; + } + + /** + * Set the value of fullname. + * + * @return Author + */ + public function setFullname(string $fullname): self + { + $this->fullname = $fullname; + + return $this; + } + + /** + * Get the value of fullname. + * + * @return string + */ + public function getFullname(): ?string + { + return $this->fullname; + } + + /** + * Set the value of email. + * + * @return Author + */ + public function setEmail(string $email): self + { + $this->email = $email; + + return $this; + } + + /** + * Get the value of email. + * + * @return string + */ + public function getEmail(): ?string + { + return $this->email; + } + + /** + * Add Post entity to collection (one to many). + * + * @return Author + */ + public function addPost(Post $post): self + { + $this->posts[] = $post; + + return $this; + } + + /** + * Remove Post entity from collection (one to many). + * + * @return Author + */ + public function removePost(Post $post): self + { + $this->posts->removeElement($post); + $post->setAuthor(null); + + return $this; + } + + /** + * Get Post entity collection (one to many). + */ + public function getPosts(): Collection + { + return $this->posts; + } +} diff --git a/tests/Provider/Doctrine/Fixtures/Entity/Standard/Comment.php b/tests/Provider/Doctrine/Fixtures/Entity/Standard/Comment.php new file mode 100644 index 00000000..eea22bb1 --- /dev/null +++ b/tests/Provider/Doctrine/Fixtures/Entity/Standard/Comment.php @@ -0,0 +1,195 @@ +id = $id; + + return $this; + } + + /** + * Get the value of id. + * + * @return int + */ + public function getId(): ?int + { + return $this->id; + } + + /** + * Set the value of body. + * + * @return Comment + */ + public function setBody(string $body): self + { + $this->body = $body; + + return $this; + } + + /** + * Get the value of body. + * + * @return string + */ + public function getBody(): ?string + { + return $this->body; + } + + /** + * Set the value of author. + * + * @return Comment + */ + public function setAuthor(string $author): self + { + $this->author = $author; + + return $this; + } + + /** + * Get the value of author. + * + * @return string + */ + public function getAuthor(): ?string + { + return $this->author; + } + + /** + * Set the value of created_at. + * + * @param ?DateTime $created_at + * + * @return Comment + */ + public function setCreatedAt(?DateTime $created_at): self + { + $this->created_at = $created_at; + + return $this; + } + + /** + * Get the value of created_at. + * + * @return ?DateTime + */ + public function getCreatedAt(): ?DateTime + { + return $this->created_at; + } + + /** + * Set the value of post_id. + * + * @return Comment + */ + public function setPostId(int $post_id): self + { + $this->post_id = $post_id; + + return $this; + } + + /** + * Get the value of post_id. + * + * @return int + */ + public function getPostId(): ?int + { + return $this->post_id; + } + + /** + * Set Post entity (many to one). + * + * @param ?Post $post + * + * @return Comment + */ + public function setPost(?Post $post): self + { + $this->post = $post; + + return $this; + } + + /** + * Get Post entity (many to one). + * + * @return ?Post + */ + public function getPost(): ?Post + { + return $this->post; + } +} diff --git a/tests/Provider/Doctrine/Fixtures/Entity/Standard/DummyEntity.php b/tests/Provider/Doctrine/Fixtures/Entity/Standard/DummyEntity.php new file mode 100644 index 00000000..8c459302 --- /dev/null +++ b/tests/Provider/Doctrine/Fixtures/Entity/Standard/DummyEntity.php @@ -0,0 +1,154 @@ +id; + } + + /** + * Get the value of name. + * + * @return mixed + */ + public function getLabel() + { + return $this->label; + } + + /** + * Set the value of name. + * + * @param mixed $label + * + * @return DummyEntity + */ + public function setLabel($label) + { + $this->label = $label; + + return $this; + } + + public function getPhpArray() + { + return $this->php_array; + } + + public function setPhpArray(array $php_array) + { + $this->php_array = $php_array; + + return $this; + } + + public function getJsonArray() + { + return $this->json_array; + } + + public function setJsonArray($json_array) + { + $this->json_array = $json_array; + + return $this; + } + + public function getSimpleArray() + { + return $this->simple_array; + } + + public function setSimpleArray($simple_array) + { + $this->simple_array = $simple_array; + + return $this; + } + + public function getIntValue() + { + return $this->int_value; + } + + public function setIntValue($int_value) + { + $this->int_value = $int_value; + + return $this; + } + + public function getDecimalValue() + { + return $this->decimal_value; + } + + public function setDecimalValue($decimal_value) + { + $this->decimal_value = $decimal_value; + + return $this; + } + + public function getBoolValue() + { + return $this->bool_value; + } + + public function setBoolValue($bool_value) + { + $this->bool_value = $bool_value; + + return $this; + } +} diff --git a/tests/Provider/Doctrine/Fixtures/Entity/Standard/Post.php b/tests/Provider/Doctrine/Fixtures/Entity/Standard/Post.php new file mode 100644 index 00000000..2b9165dc --- /dev/null +++ b/tests/Provider/Doctrine/Fixtures/Entity/Standard/Post.php @@ -0,0 +1,318 @@ +comments = new ArrayCollection(); + $this->tags = new ArrayCollection(); + } + + public function __toString() + { + return $this->title; + } + + public function __sleep() + { + return ['id', 'title', 'body', 'created_at', 'author_id']; + } + + /** + * Set the value of id. + * + * @return Post + */ + public function setId(int $id): self + { + $this->id = $id; + + return $this; + } + + /** + * Get the value of id. + * + * @return int + */ + public function getId(): ?int + { + return $this->id; + } + + /** + * Set the value of title. + * + * @return Post + */ + public function setTitle(string $title): self + { + $this->title = $title; + + return $this; + } + + /** + * Get the value of title. + * + * @return string + */ + public function getTitle(): ?string + { + return $this->title; + } + + /** + * Set the value of body. + * + * @return Post + */ + public function setBody(string $body): self + { + $this->body = $body; + + return $this; + } + + /** + * Get the value of body. + * + * @return string + */ + public function getBody(): ?string + { + return $this->body; + } + + /** + * Set the value of created_at. + * + * @param ?DateTime $created_at + * + * @return Post + */ + public function setCreatedAt(?DateTime $created_at): self + { + $this->created_at = $created_at; + + return $this; + } + + /** + * Get the value of created_at. + * + * @return ?DateTime + */ + public function getCreatedAt(): ?DateTime + { + return $this->created_at; + } + + /** + * Set the value of deleted_at. + * + * @param ?DateTime $deleted_at + * + * @return Post + */ + public function setDeletedAt(?DateTime $deleted_at): self + { + $this->deleted_at = $deleted_at; + + return $this; + } + + /** + * Get the value of deleted_at. + * + * @return ?DateTime + */ + public function getDeletedAt(): ?DateTime + { + return $this->deleted_at; + } + + /** + * Set the value of author_id. + * + * @return Post + */ + public function setAuthorId(int $author_id): self + { + $this->author_id = $author_id; + + return $this; + } + + /** + * Get the value of author_id. + * + * @return int + */ + public function getAuthorId(): ?int + { + return $this->author_id; + } + + /** + * Add Comment entity to collection (one to many). + * + * @param \DH\Auditor\Tests\Provider\Doctrine\Fixtures\Entity\Basic\Blog\DH\Auditor\Tests\Provider\Doctrine\Fixtures\Entity\Standard\Comment $comment + * + * @return Post + */ + public function addComment(Comment $comment): self + { + $this->comments[] = $comment; + + return $this; + } + + /** + * Remove Comment entity from collection (one to many). + * + * @param \DH\Auditor\Tests\Provider\Doctrine\Fixtures\Entity\Basic\Blog\DH\Auditor\Tests\Provider\Doctrine\Fixtures\Entity\Standard\Comment $comment + * + * @return Post + */ + public function removeComment(Comment $comment): self + { + $this->comments->removeElement($comment); + $comment->setPost(null); + + return $this; + } + + /** + * Get Comment entity collection (one to many). + */ + public function getComments(): Collection + { + return $this->comments; + } + + /** + * Set Author entity (many to one). + * + * @param ?Author $author + * + * @return Post + */ + public function setAuthor(?Author $author): self + { + $this->author = $author; + + return $this; + } + + /** + * Get Author entity (many to one). + * + * @return ?Author + */ + public function getAuthor(): ?Author + { + return $this->author; + } + + /** + * Add Tag entity to collection. + * + * @return Post + */ + public function addTag(Tag $tag): self + { + $tag->addPost($this); + $this->tags[] = $tag; + + return $this; + } + + /** + * Remove Tag entity from collection. + * + * @return Post + */ + public function removeTag(Tag $tag): self + { + $tag->removePost($this); + $this->tags->removeElement($tag); + + return $this; + } + + /** + * Get Tag entity collection. + */ + public function getTags(): Collection + { + return $this->tags; + } +} diff --git a/tests/Provider/Doctrine/Fixtures/Entity/Standard/Tag.php b/tests/Provider/Doctrine/Fixtures/Entity/Standard/Tag.php new file mode 100644 index 00000000..27200082 --- /dev/null +++ b/tests/Provider/Doctrine/Fixtures/Entity/Standard/Tag.php @@ -0,0 +1,121 @@ +posts = new ArrayCollection(); + } + + public function __sleep() + { + return ['id', 'title']; + } + + /** + * Set the value of id. + * + * @return Tag + */ + public function setId(int $id): self + { + $this->id = $id; + + return $this; + } + + /** + * Get the value of id. + * + * @return int + */ + public function getId(): ?int + { + return $this->id; + } + + /** + * Set the value of title. + * + * @return Tag + */ + public function setTitle(string $title): self + { + $this->title = $title; + + return $this; + } + + /** + * Get the value of title. + * + * @return string + */ + public function getTitle(): ?string + { + return $this->title; + } + + /** + * Add Post entity to collection. + * + * @param \DH\Auditor\Tests\Provider\Doctrine\Fixtures\Entity\Basic\Blog\DH\Auditor\Tests\Provider\Doctrine\Fixtures\Entity\Standard\Post $post + * + * @return Tag + */ + public function addPost(Post $post): self + { + $this->posts[] = $post; + + return $this; + } + + /** + * Remove Post entity from collection. + * + * @param \DH\Auditor\Tests\Provider\Doctrine\Fixtures\Entity\Basic\Blog\DH\Auditor\Tests\Provider\Doctrine\Fixtures\Entity\Standard\Post $post + * + * @return Tag + */ + public function removePost(Post $post): self + { + $this->posts->removeElement($post); + + return $this; + } + + /** + * Get Post entity collection. + */ + public function getPosts(): Collection + { + return $this->posts; + } +} diff --git a/tests/Provider/Doctrine/Fixtures/Issue37/Locale.php b/tests/Provider/Doctrine/Fixtures/Issue37/Locale.php new file mode 100644 index 00000000..91ab86a9 --- /dev/null +++ b/tests/Provider/Doctrine/Fixtures/Issue37/Locale.php @@ -0,0 +1,68 @@ +id; + } + + /** + * Set the value of id. + * + * @return Locale + */ + public function setId(string $id): self + { + $this->id = $id; + + return $this; + } + + /** + * Get the value of name. + */ + public function getName(): string + { + return $this->name; + } + + /** + * Set the value of name. + * + * @return Locale + */ + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } +} diff --git a/tests/Provider/Doctrine/Fixtures/Issue37/User.php b/tests/Provider/Doctrine/Fixtures/Issue37/User.php new file mode 100644 index 00000000..c8a7eff5 --- /dev/null +++ b/tests/Provider/Doctrine/Fixtures/Issue37/User.php @@ -0,0 +1,123 @@ +id; + } + + /** + * Set the value of id. + * + * @return User + */ + public function setId(int $id): self + { + $this->id = $id; + + return $this; + } + + /** + * Get the value of username. + */ + public function getUsername(): string + { + return $this->username; + } + + /** + * Set the value of username. + * + * @return User + */ + public function setUsername(string $username): self + { + $this->username = $username; + + return $this; + } + + /** + * Set the value of locale_id. + * + * @return User + */ + public function setLocaleId(string $locale_id): self + { + $this->locale_id = $locale_id; + + return $this; + } + + /** + * Get the value of locale_id. + * + * @return string + */ + public function getLocaleId(): ?string + { + return $this->locale_id; + } + + /** + * Set Locale entity (many to one). + * + * @param ?Locale $locale + * + * @return User + */ + public function setLocale(?Locale $locale): self + { + $this->locale = $locale; + + return $this; + } + + /** + * Get Locale entity (many to one). + * + * @return ?Locale + */ + public function getLocale(): ?Locale + { + return $this->locale; + } +} diff --git a/tests/Provider/Doctrine/Fixtures/Issue40/CoreCase.php b/tests/Provider/Doctrine/Fixtures/Issue40/CoreCase.php new file mode 100644 index 00000000..0f2ddba9 --- /dev/null +++ b/tests/Provider/Doctrine/Fixtures/Issue40/CoreCase.php @@ -0,0 +1,29 @@ +name; + } + + /** + * Set the value of name. + * + * @param mixed $name + */ + public function setName($name): void + { + $this->name = $name; + } +} diff --git a/tests/Provider/Doctrine/Traits/ConnectionTrait.php b/tests/Provider/Doctrine/Traits/ConnectionTrait.php new file mode 100644 index 00000000..d0fac583 --- /dev/null +++ b/tests/Provider/Doctrine/Traits/ConnectionTrait.php @@ -0,0 +1,92 @@ +createConnection(); + } + + if (false === self::$connection->ping()) { + self::$connection->close(); + self::$connection->connect(); + } + + return self::$connection; + } + + private function createConnection(): Connection + { + $params = self::getConnectionParameters(); + + if (isset( + $GLOBALS['db_type'], + $GLOBALS['db_username'], + $GLOBALS['db_password'], + $GLOBALS['db_host'], + $GLOBALS['db_name'], + $GLOBALS['db_port'] + )) { + $tmpParams = $params; + $dbname = $params['dbname']; + unset($tmpParams['dbname']); + + $conn = DriverManager::getConnection($tmpParams); + $platform = $conn->getDatabasePlatform(); + + if ($platform->supportsCreateDropDatabase()) { + $conn->getSchemaManager()->dropAndCreateDatabase($dbname); + } else { + $sm = $conn->getSchemaManager(); + $schema = $sm->createSchema(); + $stmts = $schema->toDropSql($conn->getDatabasePlatform()); + foreach ($stmts as $stmt) { + $conn->exec($stmt); + } + } + + $conn->close(); + } + + return DriverManager::getConnection($params); + } + + private static function getConnectionParameters(): array + { + if (isset( + $GLOBALS['db_type'], + $GLOBALS['db_username'], + $GLOBALS['db_password'], + $GLOBALS['db_host'], + $GLOBALS['db_name'], + $GLOBALS['db_port'] + )) { + $params = [ + 'driver' => $GLOBALS['db_type'], + 'user' => $GLOBALS['db_username'], + 'password' => $GLOBALS['db_password'], + 'host' => $GLOBALS['db_host'], + 'dbname' => $GLOBALS['db_name'], + 'port' => $GLOBALS['db_port'], + ]; + } else { + $params = [ + 'driver' => 'pdo_sqlite', + 'memory' => true, + ]; + } + + return $params; + } +} diff --git a/tests/Provider/Doctrine/Traits/DoctrineProviderTrait.php b/tests/Provider/Doctrine/Traits/DoctrineProviderTrait.php new file mode 100644 index 00000000..cc42dd3d --- /dev/null +++ b/tests/Provider/Doctrine/Traits/DoctrineProviderTrait.php @@ -0,0 +1,28 @@ +createAuditor( + $this->createAuditorConfiguration(), + new DoctrineProvider( + $configuration ?? $this->createProviderConfiguration(), + new AnnotationLoader($this->createEntityManager()) + ) + ); + + return $auditor->getProvider(); + } +} diff --git a/tests/Provider/Doctrine/Traits/EntityManagerInterfaceTrait.php b/tests/Provider/Doctrine/Traits/EntityManagerInterfaceTrait.php new file mode 100644 index 00000000..592daa43 --- /dev/null +++ b/tests/Provider/Doctrine/Traits/EntityManagerInterfaceTrait.php @@ -0,0 +1,62 @@ +setMetadataCacheImpl(new ArrayCache()); + $config->setQueryCacheImpl(new ArrayCache()); + $config->setProxyDir(__DIR__.'/Proxies'); + $config->setAutoGenerateProxyClasses(ProxyFactory::AUTOGENERATE_EVAL); + $config->setProxyNamespace('DH\Auditor\Tests\Provider\Doctrine\Proxies'); + $config->addFilter('soft-deleteable', SoftDeleteableFilter::class); + + $fixturesPath = \is_array($this->fixturesPath) ? $this->fixturesPath : [$this->fixturesPath]; + $config->setMetadataDriverImpl($config->newDefaultAnnotationDriver($fixturesPath, false)); + + DoctrineExtensions::registerAnnotations(); + + $connection = $this->getConnection(); +// $connection = $this->getSharedConnection(); + + return EntityManager::create($connection, $config); +// $this->setAuditConfiguration($this->createAuditConfiguration([], $this->em)); +// $configuration = $this->getAuditConfiguration(); +// +// $this->transactionManager = new TransactionManager($configuration); +// +// $configuration->getEventDispatcher()->addSubscriber(new AuditSubscriber($this->transactionManager)); +// +// // get rid of more global state +// $evm = $connection->getEventManager(); +// foreach ($evm->getListeners() as $event => $listeners) { +// foreach ($listeners as $listener) { +// $evm->removeEventListener([$event], $listener); +// } +// } +// $evm->addEventSubscriber(new DoctrineSubscriber($this->transactionManager)); +// $evm->addEventSubscriber(new CreateSchemaListener($this->transactionManager, $this->getReader())); +// $evm->addEventSubscriber(new Gedmo\SoftDeleteable\SoftDeleteableListener()); +// +// return $this->em; + } +} diff --git a/tests/Provider/Doctrine/Traits/ProviderConfigurationTrait.php b/tests/Provider/Doctrine/Traits/ProviderConfigurationTrait.php new file mode 100644 index 00000000..9f4d2e4f --- /dev/null +++ b/tests/Provider/Doctrine/Traits/ProviderConfigurationTrait.php @@ -0,0 +1,21 @@ + '', + 'table_suffix' => '_audit', + 'ignored_columns' => [], + 'entities' => [], + 'enabled_viewer' => true, + ], $options) + ); + } +} diff --git a/tests/Traits/AuditorConfigurationTrait.php b/tests/Traits/AuditorConfigurationTrait.php new file mode 100644 index 00000000..8d71683d --- /dev/null +++ b/tests/Traits/AuditorConfigurationTrait.php @@ -0,0 +1,18 @@ + 'UTC', + 'enabled' => true, + ], $options) + ); + } +} diff --git a/tests/Traits/AuditorTrait.php b/tests/Traits/AuditorTrait.php new file mode 100644 index 00000000..05b39791 --- /dev/null +++ b/tests/Traits/AuditorTrait.php @@ -0,0 +1,27 @@ +createAuditorConfiguration(), + $provider ?? new DummyProvider(), + $dispatcher ?? new EventDispatcher() + ); + } +}