diff --git a/src/Module/Core/Domain/Entity/Permission.php b/src/Module/Core/Domain/Entity/Permission.php index bbd70a5..907b0a0 100644 --- a/src/Module/Core/Domain/Entity/Permission.php +++ b/src/Module/Core/Domain/Entity/Permission.php @@ -8,10 +8,12 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository; +use App\Shared\Domain\Attribute\Auditable; use Doctrine\ORM\Mapping as ORM; use InvalidArgumentException; use Symfony\Component\Serializer\Attribute\Groups; +#[Auditable] #[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)] #[ORM\Table(name: 'permission')] #[ORM\Index(name: 'idx_permission_module', columns: ['module'])] diff --git a/src/Module/Core/Domain/Entity/Role.php b/src/Module/Core/Domain/Entity/Role.php index e837008..9022147 100644 --- a/src/Module/Core/Domain/Entity/Role.php +++ b/src/Module/Core/Domain/Entity/Role.php @@ -13,6 +13,7 @@ use ApiPlatform\Metadata\Post; use App\Module\Core\Domain\Exception\SystemRoleDeletionException; use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor; use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository; +use App\Shared\Domain\Attribute\Auditable; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -20,6 +21,7 @@ use InvalidArgumentException; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\SerializedName; +#[Auditable] #[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)] #[ORM\Table(name: '`role`')] #[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])] diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index 553e34a..6c699f5 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -16,6 +16,8 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\MeProvider; use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor; use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor; use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository; +use App\Shared\Domain\Attribute\Auditable; +use App\Shared\Domain\Attribute\AuditIgnore; use App\Shared\Domain\Contract\UserInterface as SharedUserInterface; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; @@ -58,6 +60,7 @@ use Symfony\Component\Serializer\Attribute\Groups; ], denormalizationContext: ['groups' => ['user:write']], )] +#[Auditable] #[ORM\Entity(repositoryClass: DoctrineUserRepository::class)] #[ORM\Table(name: '`user`')] class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedUserInterface @@ -87,6 +90,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU private array $roles = []; #[ORM\Column] + #[AuditIgnore] private ?string $password = null; #[Groups(['user:write'])] @@ -97,6 +101,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU #[ORM\Column(length: 64, unique: true, nullable: true)] #[Groups(['me:read'])] + #[AuditIgnore] private ?string $apiToken = null; #[ORM\Column(length: 255, nullable: true)] diff --git a/src/Module/Core/Infrastructure/Doctrine/AuditListener.php b/src/Module/Core/Infrastructure/Doctrine/AuditListener.php new file mode 100644 index 0000000..c478cda --- /dev/null +++ b/src/Module/Core/Infrastructure/Doctrine/AuditListener.php @@ -0,0 +1,513 @@ +getId()`). + * - Les DELETE / UPDATE bulk DQL et les `Connection::executeStatement()` + * bruts BYPASSENT le listener : onFlush n'est jamais appele. Toute + * operation de purge/nettoyage qui doit etre auditee doit passer par + * `EntityManager::remove()` + `flush()`. Si un futur batch (ex: commande + * "purger users inactifs") utilise du DQL bulk, les suppressions ne + * seront pas dans `audit_log` — choix d'architecture explicite a faire. + */ +#[AsDoctrineListener(event: Events::onFlush)] +#[AsDoctrineListener(event: Events::postFlush)] +final class AuditListener +{ + /** + * Cache par FQCN : true si la classe porte #[Auditable], false sinon. + * Evite une ReflectionClass par entite a chaque flush. + * + * @var array + */ + private array $auditableCache = []; + + /** + * Cache par FQCN : liste des noms de proprietes ignorees (#[AuditIgnore]). + * + * @var array> + */ + private array $ignoredPropertiesCache = []; + + /** + * Logs en attente d'ecriture (remplis en onFlush, consommes en postFlush). + * + * Pour les inserts, l'ID est assignee DURANT le flush : on capture la + * reference de l'entite et on resout l'ID au moment du postFlush. + * + * @var list, capturedId: ?string}> + */ + private array $pendingLogs = []; + + public function __construct( + private readonly AuditLogWriter $writer, + private readonly LoggerInterface $logger, + ) {} + + public function onFlush(OnFlushEventArgs $args): void + { + /** @var EntityManagerInterface $em */ + $em = $args->getObjectManager(); + $uow = $em->getUnitOfWork(); + + // Reset defensif en debut de cycle : si un flush precedent a leve une + // exception, Doctrine n'appelle PAS postFlush et pendingLogs reste + // rempli avec des changements jamais committes. Sans ce reset, un + // flush ulterieur reussi ecrirait les fausses entrees dans audit_log. + // Le swap-and-clear dans postFlush couvre deja les flushes re-entrants, + // ce reset ne le fragilise donc pas. + $this->pendingLogs = []; + + foreach ($uow->getScheduledEntityInsertions() as $entity) { + $this->capturePendingLog($entity, $em, $uow, 'create'); + } + + foreach ($uow->getScheduledEntityUpdates() as $entity) { + $this->capturePendingLog($entity, $em, $uow, 'update'); + } + + foreach ($uow->getScheduledEntityDeletions() as $entity) { + $this->capturePendingLog($entity, $em, $uow, 'delete'); + } + + // Collections to-many (OneToMany / ManyToMany) : `getEntityChangeSet()` + // ne les expose pas, il faut interroger `UnitOfWork` separement. On + // merge la diff dans le log de l'entite proprietaire si elle est deja + // scheduled, sinon on cree une entree "update" dediee. + foreach ($uow->getScheduledCollectionUpdates() as $collection) { + $this->captureCollectionChange($collection, $em, cleared: false); + } + + foreach ($uow->getScheduledCollectionDeletions() as $collection) { + $this->captureCollectionChange($collection, $em, cleared: true); + } + } + + public function postFlush(PostFlushEventArgs $args): void + { + // Swap-and-clear : protege d'un flush re-entrant (aucune double + // insertion meme si un callback utilisateur re-declenche un flush). + $logs = $this->pendingLogs; + $this->pendingLogs = []; + + foreach ($logs as $log) { + // Pour les inserts, l'ID n'etait pas encore disponible en onFlush : + // on la resout maintenant (Doctrine l'a hydratee pendant le flush). + $entityId = $log['capturedId'] ?? $this->resolveEntityId($log['entity'], $log['metadata']); + + if (null === $entityId) { + $this->logger->warning( + 'AuditListener : impossible de resoudre l\'ID de l\'entite apres flush, entree ignoree', + ['entityType' => $log['entityType'], 'action' => $log['action']] + ); + + continue; + } + + try { + $this->writer->log( + $log['entityType'], + $entityId, + $log['action'], + $log['changes'], + ); + } catch (Throwable $e) { + // Erreur audit : logue mais ne crashe jamais le flux metier. + $this->logger->error( + 'Echec d\'ecriture audit_log', + [ + 'exception' => $e, + 'entityType' => $log['entityType'], + 'entityId' => $entityId, + 'action' => $log['action'], + ] + ); + } + } + } + + private function capturePendingLog(object $entity, EntityManagerInterface $em, UnitOfWork $uow, string $action): void + { + // Resolution via ClassMetadata : `$entity::class` renvoie le FQCN du + // proxy Doctrine pour une entite chargee en lazy (ex: + // `Proxies\__CG__\App\Module\Core\Domain\Entity\User`) — `isAuditable()` + // le verrait comme non-auditable car `#[Auditable]` n'est declare que + // sur la classe parente. + $metadata = $em->getClassMetadata($entity::class); + $class = $metadata->getName(); + + if (!$this->isAuditable($class)) { + return; + } + + // Sur `delete`, on inclut aussi les collections to-many dans le + // snapshot : c'est la derniere occasion de capturer l'etat complet + // (ex: quelles permissions etaient rattachees au role supprime). + // Sur `create`, les collections initiales sont rapportees via + // captureCollectionChange quand l'entite est scheduled avec un + // collection update dans le meme flush. + $changes = match ($action) { + 'update' => $this->buildUpdateChanges($entity, $uow, $class), + 'create' => $this->buildSnapshot($entity, $metadata, $class, includeCollections: false), + 'delete' => $this->buildSnapshot($entity, $metadata, $class, includeCollections: true), + default => [], + }; + + if ('update' === $action && [] === $changes) { + // Flush sans changement reel sur une entite auditable : on n'emet pas. + return; + } + + // Pour delete/update, l'ID est deja set en onFlush — on la capture + // maintenant (apres postFlush, l'entite detachee peut perdre sa ref + // dans l'identity map). Pour create (IDENTITY), l'ID est generee par + // le flush — on differe a postFlush. + $capturedId = 'create' === $action ? null : $this->resolveEntityId($entity, $metadata); + + $this->pendingLogs[] = [ + 'entity' => $entity, + 'metadata' => $metadata, + 'entityType' => $this->formatEntityType($class), + 'action' => $action, + 'changes' => $changes, + 'capturedId' => $capturedId, + ]; + } + + /** + * Capture la modification d'une collection to-many. + * + * Strategie de merge : + * - Si l'entite proprietaire est deja scheduled pour `delete` → ignore + * (redondant avec le snapshot delete deja produit). + * - Si l'entite est deja scheduled pour `create` → on ajoute le champ + * collection au snapshot initial, sous forme de liste d'IDs ajoutes. + * - Si l'entite est deja scheduled pour `update` → on merge la diff + * {added, removed} dans le changeset existant. + * - Sinon → on cree une nouvelle entree `update` dediee pour l'entite + * proprietaire (cas d'une collection modifiee sans autre changement + * sur l'entite elle-meme, ex : ajout d'une permission a un role). + * + * @param bool $cleared true si la collection entiere est supprimee + * (getScheduledCollectionDeletions) — tous les + * items du snapshot sont consideres comme retires + */ + private function captureCollectionChange(PersistentCollection $collection, EntityManagerInterface $em, bool $cleared): void + { + $owner = $collection->getOwner(); + if (null === $owner) { + return; + } + + // Voir capturePendingLog : meme contournement proxy Doctrine. + $class = $em->getClassMetadata($owner::class)->getName(); + if (!$this->isAuditable($class)) { + return; + } + + $fieldName = $collection->getMapping()->fieldName; + if (in_array($fieldName, $this->getIgnoredProperties($class), true)) { + return; + } + + if ($cleared) { + $added = []; + $removed = array_map( + fn ($item): mixed => $this->normalizeValue($item), + $collection->getSnapshot(), + ); + } else { + $added = array_map( + fn ($item): mixed => $this->normalizeValue($item), + $collection->getInsertDiff(), + ); + $removed = array_map( + fn ($item): mixed => $this->normalizeValue($item), + $collection->getDeleteDiff(), + ); + } + + if ([] === $added && [] === $removed) { + return; + } + + // Chercher un log deja en attente pour cette entite, pour merger la + // diff au lieu de creer une entree d'audit redondante. + foreach ($this->pendingLogs as $idx => $log) { + if ($log['entity'] !== $owner) { + continue; + } + + if ('delete' === $log['action']) { + // Deletion de l'entite : la collection suit mecaniquement, + // pas d'entree dediee (le snapshot delete contient deja + // l'etat a supprimer). + return; + } + + if ('create' === $log['action']) { + // Insertion : le snapshot create ne contient pas les + // collections (buildSnapshot ignore les to-many). On ajoute + // donc la liste des items initiaux comme IDs, pour avoir + // une trace complete de l'etat a la creation. array_values + // garantit un array JSON (pas un objet) si les cles du diff + // ne sont pas sequentielles. + $this->pendingLogs[$idx]['changes'][$fieldName] = array_values($added); + + return; + } + + // Update : on merge dans le changeset existant. + $this->pendingLogs[$idx]['changes'][$fieldName] = [ + 'added' => array_values($added), + 'removed' => array_values($removed), + ]; + + return; + } + + // Aucun log existant : l'entite n'a eu QUE des changements de + // collection. On cree une entree update minimale. + $metadata = $em->getClassMetadata($class); + + $this->pendingLogs[] = [ + 'entity' => $owner, + 'metadata' => $metadata, + 'entityType' => $this->formatEntityType($class), + 'action' => 'update', + 'changes' => [$fieldName => [ + 'added' => array_values($added), + 'removed' => array_values($removed), + ]], + 'capturedId' => $this->resolveEntityId($owner, $metadata), + ]; + } + + /** + * Build du changeset "update" : {champ: {old, new}} a partir de + * `UnitOfWork::getEntityChangeSet()`. ManyToOne : on log l'ID, + * null-safe via `?->getId()`. + * + * @return array + */ + private function buildUpdateChanges(object $entity, UnitOfWork $uow, string $class): array + { + $changeSet = $uow->getEntityChangeSet($entity); + $ignored = $this->getIgnoredProperties($class); + $filteredChanges = []; + + foreach ($changeSet as $field => [$oldValue, $newValue]) { + if (in_array($field, $ignored, true)) { + continue; + } + + $filteredChanges[$field] = [ + 'old' => $this->normalizeValue($oldValue), + 'new' => $this->normalizeValue($newValue), + ]; + } + + return $filteredChanges; + } + + /** + * Build d'un snapshot complet (create / delete) : lit toutes les + * proprietes non-ignorees via Reflection. + * + * @param bool $includeCollections si true, les associations to-many sont + * aussi snapshotees (liste d'IDs). Utilise + * uniquement sur `delete` pour preserver + * l'etat des relations au moment de la + * suppression. En create, on laisse + * captureCollectionChange enrichir le + * snapshot si une collection est modifiee + * dans le meme flush. + * + * @return array + */ + private function buildSnapshot(object $entity, ClassMetadata $metadata, string $class, bool $includeCollections): array + { + $ignored = $this->getIgnoredProperties($class); + $snapshot = []; + + foreach ($metadata->getFieldNames() as $field) { + if (in_array($field, $ignored, true)) { + continue; + } + + $snapshot[$field] = $this->normalizeValue($metadata->getFieldValue($entity, $field)); + } + + foreach ($metadata->getAssociationNames() as $assoc) { + if (in_array($assoc, $ignored, true)) { + continue; + } + + if ($metadata->isSingleValuedAssociation($assoc)) { + $related = $metadata->getFieldValue($entity, $assoc); + $snapshot[$assoc] = null !== $related && method_exists($related, 'getId') + ? $related->getId() + : null; + + continue; + } + + if (!$includeCollections) { + continue; + } + + // Collection to-many : snapshot = liste d'IDs. On itere la + // Collection (PersistentCollection ou ArrayCollection) pour + // obtenir les elements. Pour un delete, la collection est deja + // chargee (Doctrine en a besoin pour les cascades). + $collection = $metadata->getFieldValue($entity, $assoc); + if (!is_iterable($collection)) { + continue; + } + $ids = []; + foreach ($collection as $item) { + $ids[] = $this->normalizeValue($item); + } + $snapshot[$assoc] = $ids; + } + + return $snapshot; + } + + private function isAuditable(string $class): bool + { + if (array_key_exists($class, $this->auditableCache)) { + return $this->auditableCache[$class]; + } + + $reflection = new ReflectionClass($class); + $isAuditable = [] !== $reflection->getAttributes(Auditable::class); + $this->auditableCache[$class] = $isAuditable; + + return $isAuditable; + } + + /** + * @return list + */ + private function getIgnoredProperties(string $class): array + { + if (array_key_exists($class, $this->ignoredPropertiesCache)) { + return $this->ignoredPropertiesCache[$class]; + } + + $ignored = []; + $reflection = new ReflectionClass($class); + + foreach ($reflection->getProperties(ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PRIVATE | ReflectionProperty::IS_PUBLIC) as $property) { + if ([] !== $property->getAttributes(AuditIgnore::class)) { + $ignored[] = $property->getName(); + } + } + + $this->ignoredPropertiesCache[$class] = $ignored; + + return $ignored; + } + + /** + * Transforme un FQCN `App\Module\Core\Domain\Entity\User` en `core.User`. + * + * Format `module.Entity` pour eviter les collisions inter-modules. + */ + private function formatEntityType(string $class): string + { + if (1 === preg_match('#^App\\\Module\\\(?[^\\\]+)\\\.+\\\(?[^\\\]+)$#', $class, $matches)) { + return strtolower($matches['module']).'.'.$matches['entity']; + } + + // Fallback : on retourne le FQCN complet si la regex ne matche pas + // (entite hors structure modulaire — ne devrait pas arriver). + return $class; + } + + private function resolveEntityId(object $entity, ClassMetadata $metadata): ?string + { + $identifier = $metadata->getIdentifierValues($entity); + if ([] === $identifier) { + return null; + } + + // Cle composee : on concatene les valeurs. Cas rare sur le projet. + return implode('-', array_map(static fn ($v) => (string) $v, $identifier)); + } + + /** + * Normalise une valeur pour encodage JSON stable. + */ + private function normalizeValue(mixed $value): mixed + { + if ($value instanceof DateTimeInterface) { + return $value->format(DateTimeInterface::ATOM); + } + + if (is_object($value)) { + // Relation to-one non parsee par buildSnapshot (cas update sur + // un champ qui devient un objet) : on tente getId() si possible. + if (method_exists($value, 'getId')) { + return $value->getId(); + } + + return (string) $value; + } + + return $value; + } +} diff --git a/tests/Functional/Module/Core/AuditListenerTest.php b/tests/Functional/Module/Core/AuditListenerTest.php new file mode 100644 index 0000000..48e979b --- /dev/null +++ b/tests/Functional/Module/Core/AuditListenerTest.php @@ -0,0 +1,108 @@ +em = $container->get(EntityManagerInterface::class); + $this->auditConnection = $container->get('doctrine.dbal.audit_connection'); + // Clean slate for deterministic assertions: these tests are not wrapped + // in a rolled-back transaction, so remove any leftover rows from a + // previous run before each test. + $this->em->getConnection()->executeStatement("DELETE FROM \"user\" WHERE username LIKE 'audit\\_%'"); + $this->auditConnection->executeStatement('DELETE FROM audit_log'); + } + + protected function tearDown(): void + { + parent::tearDown(); + unset($this->em, $this->auditConnection); + } + + public function testCreateUserIsAudited(): void + { + $user = $this->makeUser('audit_create_user'); + $this->em->persist($user); + $this->em->flush(); + + $rows = $this->fetchLogs('core.User', (string) $user->getId()); + self::assertCount(1, $rows); + self::assertSame('create', $rows[0]['action']); + $changes = json_decode((string) $rows[0]['changes'], true); + self::assertArrayHasKey('username', $changes); + self::assertArrayNotHasKey('password', $changes, 'password must be excluded via #[AuditIgnore]'); + self::assertArrayNotHasKey('apiToken', $changes, 'apiToken must be excluded via #[AuditIgnore]'); + } + + public function testUpdateUserIsAuditedWithDiff(): void + { + $user = $this->makeUser('audit_update_user'); + $this->em->persist($user); + $this->em->flush(); + $this->auditConnection->executeStatement('DELETE FROM audit_log'); + + $user->setFirstName('Changed'); + $this->em->flush(); + + $rows = $this->fetchLogs('core.User', (string) $user->getId()); + self::assertCount(1, $rows); + self::assertSame('update', $rows[0]['action']); + $changes = json_decode((string) $rows[0]['changes'], true); + self::assertArrayHasKey('firstName', $changes); + self::assertSame('Changed', $changes['firstName']['new']); + } + + public function testDeleteUserIsAudited(): void + { + $user = $this->makeUser('audit_delete_user'); + $this->em->persist($user); + $this->em->flush(); + $id = (string) $user->getId(); + $this->auditConnection->executeStatement('DELETE FROM audit_log'); + + $this->em->remove($user); + $this->em->flush(); + + $rows = $this->fetchLogs('core.User', $id); + self::assertCount(1, $rows); + self::assertSame('delete', $rows[0]['action']); + } + + private function makeUser(string $username): User + { + $user = new User(); + $user->setUsername($username); + $user->setPassword('hashed-secret'); + $user->setRoles(['ROLE_USER']); + + return $user; + } + + /** + * @return list> + */ + private function fetchLogs(string $entityType, string $entityId): array + { + return $this->auditConnection->fetchAllAssociative( + 'SELECT action, changes FROM audit_log WHERE entity_type = :t AND entity_id = :id ORDER BY performed_at ASC', + ['t' => $entityType, 'id' => $entityId], + ); + } +}