feat : audit log (table + writer + listener + API + admin UI + timeline) #9

Merged
matthieu merged 38 commits from feat/audit-log into develop 2026-05-13 08:29:31 +00:00
6 changed files with 51 additions and 17 deletions
Showing only changes of commit 18e79a643b - Show all commits
+7
View File
@@ -26,6 +26,13 @@ doctrine:
identity_generation_preferences: identity_generation_preferences:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true auto_mapping: true
# Mapping contrat DDD → classe concrete. Permet au module Core de
# referencer `SiteInterface` dans ses ORM mappings (User) sans importer
# la classe concrete du module Sites. Pattern officiel Doctrine pour
# les bounded contexts, remplace l'ancien import direct
# `App\Module\Sites\Domain\Entity\Site` dans User.php.
resolve_target_entities:
App\Shared\Domain\Contract\SiteInterface: App\Module\Sites\Domain\Entity\Site
mappings: mappings:
Core: Core:
type: attribute type: attribute
+3
View File
@@ -28,5 +28,8 @@ services:
App\Module\Sites\Domain\Repository\SiteRepositoryInterface: App\Module\Sites\Domain\Repository\SiteRepositoryInterface:
alias: App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository alias: App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository
App\Shared\Domain\Contract\SiteProviderInterface:
alias: App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository
App\Module\Sites\Application\Service\CurrentSiteProviderInterface: App\Module\Sites\Application\Service\CurrentSiteProviderInterface:
alias: App\Module\Sites\Application\Service\CurrentSiteProvider alias: App\Module\Sites\Application\Service\CurrentSiteProvider
+13 -11
View File
@@ -15,12 +15,11 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor; use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider; use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository; use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
// Note architecture : User.php utilise SiteInterface (Shared) pour les // Note architecture : User.php n'importe plus rien depuis le module Sites.
// type-hints afin de ne pas coupler le module Core au module Sites. // Les type-hints utilisent SiteInterface (Shared/Contract) et le mapping ORM
// La seule reference concrete a Site subsiste dans les metadonnees ORM // pointe vers la meme interface, resolue vers la classe concrete Site au boot
// (targetEntity) via FQCN string, ce qui est obligatoire pour Doctrine. // via `doctrine.orm.resolve_target_entities` (cf. config/packages/doctrine.yaml).
// SiteNotAuthorizedException est importee depuis Shared (sa location canonique). // C'est le pattern officiel Doctrine pour les bounded contexts DDD.
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore; use App\Shared\Domain\Attribute\AuditIgnore;
use App\Shared\Domain\Contract\SiteInterface; use App\Shared\Domain\Contract\SiteInterface;
@@ -139,10 +138,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* Le groupe `user:list` a ete retire deliberement (securite : evite * Le groupe `user:list` a ete retire deliberement (securite : evite
* de fuiter la liste des sites de chaque user via GET /api/users). * de fuiter la liste des sites de chaque user via GET /api/users).
* *
* @var Collection<int, Site> * @var Collection<int, SiteInterface>
*/ */
#[ORM\ManyToMany(targetEntity: 'App\Module\Sites\Domain\Entity\Site', inversedBy: 'users', fetch: 'LAZY')] #[ORM\ManyToMany(targetEntity: SiteInterface::class, inversedBy: 'users', fetch: 'LAZY')]
#[ORM\JoinTable(name: 'user_site')] #[ORM\JoinTable(name: 'user_site')]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[Groups(['me:read', 'user:rbac:read', 'user:rbac:write'])] #[Groups(['me:read', 'user:rbac:read', 'user:rbac:write'])]
private Collection $sites; private Collection $sites;
@@ -162,7 +163,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* le prechargement pour /api/me. Le groupe `user:list` a ete retire * le prechargement pour /api/me. Le groupe `user:list` a ete retire
* deliberement (securite : evite de fuiter le site actif via /api/users). * deliberement (securite : evite de fuiter le site actif via /api/users).
*/ */
#[ORM\ManyToOne(targetEntity: 'App\Module\Sites\Domain\Entity\Site', fetch: 'LAZY')] #[ORM\ManyToOne(targetEntity: SiteInterface::class, fetch: 'LAZY')]
#[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] #[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['me:read'])] #[Groups(['me:read'])]
private ?SiteInterface $currentSite = null; private ?SiteInterface $currentSite = null;
@@ -378,7 +379,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
} }
/** /**
* @return Collection<int, Site> * @return Collection<int, SiteInterface>
*/ */
public function getSites(): Collection public function getSites(): Collection
{ {
@@ -392,7 +393,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* session Doctrine (cf. ticket 2 review point #1). * session Doctrine (cf. ticket 2 review point #1).
* *
* Le parametre est type SiteInterface pour eviter le couplage Core → Sites. * Le parametre est type SiteInterface pour eviter le couplage Core → Sites.
* En pratique seule App\Module\Sites\Domain\Entity\Site est passee ici. * La classe concrete injectee au runtime est resolue par Doctrine via
* `resolve_target_entities` (cf. note architecture en tete de fichier).
*/ */
public function addSite(SiteInterface $site): static public function addSite(SiteInterface $site): static
{ {
@@ -8,9 +8,9 @@ use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User; use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface; use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles; use App\Module\Core\Domain\Security\SystemRoles;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Sites\Domain\Repository\SiteRepositoryInterface;
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures; use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SiteProviderInterface;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager; use Doctrine\Persistence\ObjectManager;
@@ -39,7 +39,7 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
public function __construct( public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher, private readonly UserPasswordHasherInterface $passwordHasher,
private readonly RoleRepositoryInterface $roleRepository, private readonly RoleRepositoryInterface $roleRepository,
private readonly SiteRepositoryInterface $siteRepository, private readonly SiteProviderInterface $siteProvider,
) {} ) {}
/** /**
@@ -135,9 +135,9 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
return $role; return $role;
} }
private function requireSite(string $name): Site private function requireSite(string $name): SiteInterface
{ {
$site = $this->siteRepository->findByName($name); $site = $this->siteProvider->findByName($name);
if (null === $site) { if (null === $site) {
throw new RuntimeException(sprintf( throw new RuntimeException(sprintf(
@@ -5,8 +5,9 @@ declare(strict_types=1);
namespace App\Module\Sites\Domain\Repository; namespace App\Module\Sites\Domain\Repository;
use App\Module\Sites\Domain\Entity\Site; use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Contract\SiteProviderInterface;
interface SiteRepositoryInterface interface SiteRepositoryInterface extends SiteProviderInterface
{ {
public function findById(int $id): ?Site; public function findById(int $id): ?Site;
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat minimal pour acceder a un site depuis un module qui n'est pas Sites.
*
* Permet a du code Core/Shared (commandes de seed, fixtures, etc.) de
* recuperer un Site par son nom sans importer directement depuis le module
* Sites — ce qui violerait la regle "jamais d'import direct entre modules"
* (cf. CLAUDE.md section "Regles d'architecture").
*
* Implementation concrete : App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository
* (via SiteRepositoryInterface qui etend ce contrat).
*/
interface SiteProviderInterface
{
public function findByName(string $name): ?SiteInterface;
}