feat : audit log (table + writer + listener + API + admin UI + timeline) (#9)
Some checks failed
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Failing after 9s

## Résumé

Implémente le journal d'audit append-only couvrant les 5 tickets de `doc/audit-log.md` et embarque au passage plusieurs corrections périphériques (sidebar Admin/Mon compte, drawer RBAC, Swagger, schema_filter Doctrine) ainsi que l'initialisation de la suite e2e Playwright. Toutes les mutations Doctrine sur les entités portant `#[Auditable]` sont tracées dans une table PostgreSQL dédiée, exposée en lecture seule via API Platform et consultable par les admins dans une page dédiée.

## Ce qui change

### Audit log — cœur de la PR

**Backend**

- Migration : table `audit_log` (UUID v7 natif Postgres en PK, `jsonb changes`, 3 index pour tri chrono, par entité et par utilisateur).
- `AuditLogWriter` : service bas-niveau, écrit via une connexion DBAL dédiée `audit` (même DSN que `default`, service séparé) pour sortir de la transaction ORM en batch. Blacklist defense-in-depth `password`/`plainPassword`/`token`/`secret`.
- `RequestIdProvider` : UUID v4 généré au `kernel.request` principal, injecté dans chaque ligne d'audit de la requête.
- Attributs `#[Auditable]` / `#[AuditIgnore]` dans `src/Shared/Domain/Attribute/` (accessibles par tous les modules).
- `AuditListener` : capture `onFlush` / écriture `postFlush` avec pattern swap-and-clear contre les flushes ré-entrants. Erreurs loguées, jamais propagées. Entité `User` annotée (password / plainPassword ignorés).
- API Platform read-only `/api/audit-logs` (permission RBAC `core.audit_log.view`) : `GET` collection paginée + `GET` item, pas de POST/PUT/PATCH/DELETE. Filtres `entity_type`, `entity_id`, `action`, `performed_by`, `performed_at[after]`/`[before]`.
- `DbalPaginator` implémentant `PaginatorInterface` : `hydra:view` généré automatiquement par API Platform, pas de construction manuelle.
- Ressource `AuditLogEntityTypesResource` + provider dédié pour peupler le filtre par type d'entité côté UI (réponse cachée, pas de requête à chaque ouverture du drawer).
- Permission `core.audit_log.view` déclarée dans `CoreModule::permissions()`.
- `audit_log` exclu du `schema_filter` Doctrine : plus de faux diff sur `make migration-diff`.

**Frontend**

- Page admin `/admin/audit-log` : tableau paginé, filtres locaux (état dans le composant, non persistés dans l'URL — conforme règle CLAUDE.md « Tableaux : pas de persistance URL »), drawer de détail (diff + timeline complète de l'entité), badges colorés par action.
- Composable partagé `useAuditLog` avec `resetAuditLog()` auto-enregistré sur `onAuthSessionCleared` (règle CLAUDE.md composables singletons).
- Composant réutilisable `<AuditTimeline :entity-type :entity-id>` : garde permission (pas d'appel API sans le droit), lazy loading (10 items + bouton « Voir plus »), dates relatives FR via `Intl.RelativeTimeFormat`, skeleton loader.
- Entrée sidebar « Journal d'audit » gated sur `core.audit_log.view` + clés i18n imbriquées dans `fr.json`.

### Fixes embarqués

- **Review fixes audit-log** (commits `37eafd2`, `1505e84`, `99c77eb`) : précision des timestamps, `ESCAPE` sur les `LIKE`, plafond pagination, diverses remarques du 1er tour de review.
- **Sidebar** (`701a480`, `e2fbf51`) : nouvelle section « Administration » + groupe « Mon compte », gate de section sur permissions, « Tableau de bord » déplacé dans « Mon compte ». Convention admin documentée.
- **Drawer RBAC utilisateurs** (`617ee31`, `5f5afcc`) : corrige l'affichage des sites et l'écrasement via merge-patch (garde anti-écrasement + spec `GET /users/{id}/rbac` documentée).
- **Swagger UI** (`6db955f`) : réactivé en ajoutant `symfony/twig-bundle` aux deps (régression depuis l'arrivée d'API Platform 4.2).
- **`phpunit.dist.xml`** : `<env APP_ENV=dev>` forçait la suite à tourner sous `framework.test=false` (→ `test.service_container` introuvable) ; `JWT_PASSPHRASE` ne matchait pas les clés de dev. Corrigés pour débloquer la suite.

### E2E Playwright (nouveau, commit `4603ab2`)

- `playwright.config.ts` + structure `frontend/tests/e2e/` (personas, helpers `loginAs`, page objects `LoginPage` + `SidebarComponent`).
- Specs : `auth/login.spec.ts` + `permissions/sidebar-visibility.spec.ts` (vérifie la visibilité de la sidebar par rôle RBAC).
- Commande `SeedE2ECommand` pour préparer un jeu de données déterministe côté backend.
- `make e2e` ajouté au Makefile.

## Décisions techniques

- **UUID v7 natif Postgres** (16 octets vs 36 en varchar) : index `performed_at` ~40 % plus petit sur une table append-only à croissance infinie.
- **`entity_type` format `module.Entity`** (ex: `core.User`) : évite les collisions si deux modules ont des entités de même nom.
- **`performed_by` dénormalisé** (string, pas FK) : le nom persiste même après suppression de l'utilisateur.
- **Connexion DBAL dédiée `audit`** : évite l'entanglement transactionnel entre audit et ORM en batch.
- **`ManyToMany` non audité** : limitation connue (`getEntityChangeSet()` ne couvre pas les collections) ; extension future via `getScheduledCollectionUpdates()` si besoin.
- **Filtres locaux non persistés dans l'URL** : choix assumé (cf. CLAUDE.md) pour éviter le couplage table ↔ routeur.

## Test plan

- [x] `make test` : 218 tests passent (writer unitaires + listener intégration + API fonctionnels + UserRbacProcessor).
- [x] `npm run lint` + `npm run test` + `npm run build` (frontend).
- [x] Migration appliquée sur dev + test, `audit_log` ignoré par `schema_filter`.
- [x] Permissions synchronisées (`app:sync-permissions`).
- [x] Swagger `/api/docs` accessible de nouveau.
- [ ] Playwright : `make e2e` vert en local (login + sidebar-visibility).
- [ ] Vérifier en local : création/modif/suppression d'un user apparaît dans `/admin/audit-log`.
- [ ] Vérifier : user sans `core.audit_log.view` → 403 sur l'endpoint + item absent de la sidebar.
- [ ] Vérifier : expansion d'une ligne affiche la timeline de l'entité avec dates relatives FR.
- [ ] Vérifier : drawer RBAC utilisateur n'écrase plus la liste des sites au `PATCH`.

## Points d'attention pour le review

- `AuditListener` : pattern swap-and-clear sur `postFlush` — relire la gestion des flushes ré-entrants.
- `DbalPaginator` : vérifier que l'absence d'`Iterator` custom ne casse pas la normalisation API Platform sur collections vides.
- `UserRbacProcessor` : logique merge-patch + garde anti-écrasement des sites (régression corrigée dans `617ee31`).
- Playwright : nouvelle dépendance de dev, s'assurer que `make e2e` ne fait pas partie du pipeline CI par défaut (à brancher explicitement).

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: MALIO-DEV/Coltura#9
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Co-committed-by: matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #9.
This commit is contained in:
2026-05-13 08:29:30 +00:00
parent dce189d982
commit e6c8381b3c
92 changed files with 8543 additions and 2309 deletions

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Application\DTO;
use DateTimeImmutable;
/**
* DTO de sortie pour une ligne d'audit.
*
* Readonly : aucune mutation possible apres hydration. La resource API
* Platform expose directement ce DTO (pas d'entite sous-jacente car la
* table audit_log n'est pas geree par l'ORM).
*/
final readonly class AuditLogOutput
{
public function __construct(
public string $id,
public string $entityType,
public string $entityId,
public string $action,
/** @var array<string, mixed> */
public array $changes,
public string $performedBy,
public DateTimeImmutable $performedAt,
public ?string $ipAddress,
public ?string $requestId,
) {}
}

View File

@@ -34,6 +34,8 @@ final class CoreModule
['code' => 'core.users.manage', 'label' => 'Gerer les utilisateurs (creer, editer, supprimer)'],
['code' => 'core.roles.view', 'label' => 'Voir les roles RBAC'],
['code' => 'core.roles.manage', 'label' => 'Gerer les roles et permissions'],
['code' => 'core.permissions.view', 'label' => 'Consulter le catalogue des permissions RBAC'],
['code' => 'core.audit_log.view', 'label' => 'Consulter le journal d\'audit'],
];
}
}

View File

@@ -11,19 +11,27 @@ 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;
#[ApiResource(
operations: [
// Guard RBAC du catalogue de permissions : accepte les gestionnaires
// de users et de roles en plus du code dedie `core.permissions.view`.
// Justification : les drawers `UserRbacDrawer`/`RoleDrawer` fetchent
// systematiquement ce catalogue pour afficher les checkboxes de
// permissions ; exiger uniquement `core.permissions.view` casserait
// ces workflows pour tout gestionnaire non-admin. L'endpoint n'expose
// que des codes/libelles (pas de secret), le bypass reste acceptable.
new GetCollection(
normalizationContext: ['groups' => ['permission:read']],
security: "is_granted('ROLE_USER')",
security: "is_granted('core.permissions.view') or is_granted('core.users.manage') or is_granted('core.roles.manage')",
),
new Get(
normalizationContext: ['groups' => ['permission:read']],
security: "is_granted('ROLE_USER')",
security: "is_granted('core.permissions.view') or is_granted('core.users.manage') or is_granted('core.roles.manage')",
),
],
)]
@@ -31,6 +39,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiFilter(BooleanFilter::class, properties: ['orphan'])]
#[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)]
#[ORM\Table(name: 'permission')]
#[Auditable]
#[ORM\UniqueConstraint(name: 'uniq_permission_code', columns: ['code'])]
#[ORM\Index(name: 'idx_permission_module', columns: ['module'])]
#[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])]

View File

@@ -15,6 +15,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\DBAL\Types\Types;
@@ -64,6 +65,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiFilter(BooleanFilter::class, properties: ['isSystem'])]
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
#[ORM\Table(name: '`role`')]
#[Auditable]
#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])]
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
#[UniqueEntity(fields: ['code'], message: 'Un role avec ce code existe deja.')]

View File

@@ -15,12 +15,13 @@ 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\Provider\MeProvider;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
// Note architecture : User.php utilise SiteInterface (Shared) pour les
// type-hints afin de ne pas coupler le module Core au module Sites.
// La seule reference concrete a Site subsiste dans les metadonnees ORM
// (targetEntity) via FQCN string, ce qui est obligatoire pour Doctrine.
// SiteNotAuthorizedException est importee depuis Shared (sa location canonique).
use App\Module\Sites\Domain\Entity\Site;
// Note architecture : User.php n'importe plus rien depuis le module Sites.
// Les type-hints utilisent SiteInterface (Shared/Contract) et le mapping ORM
// pointe vers la meme interface, resolue vers la classe concrete Site au boot
// via `doctrine.orm.resolve_target_entities` (cf. config/packages/doctrine.yaml).
// C'est le pattern officiel Doctrine pour les bounded contexts DDD.
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Exception\SiteNotAuthorizedException;
use DateTimeImmutable;
@@ -49,6 +50,16 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
),
new Post(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
new Patch(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
// Lecture dediee au drawer d'edition RBAC : meme URI que le PATCH pour une
// API symetrique, groupe `user:rbac:read` qui expose sites/roles/directPermissions.
// Garde `core.users.manage` (pas `.view`) car c'est l'endpoint de detail prevu
// pour l'edition, pas la consultation generale (elle passe par GET /users/{id}).
new Get(
name: 'user_rbac_get',
uriTemplate: '/users/{id}/rbac',
security: "is_granted('core.users.manage')",
normalizationContext: ['groups' => ['user:rbac:read']],
),
new Patch(
name: 'user_rbac_patch',
uriTemplate: '/users/{id}/rbac',
@@ -63,6 +74,7 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
)]
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
#[ORM\Table(name: '`user`')]
#[Auditable]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
@@ -126,10 +138,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* Le groupe `user:list` a ete retire deliberement (securite : evite
* 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\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'])]
private Collection $sites;
@@ -149,15 +163,17 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* le prechargement pour /api/me. Le groupe `user:list` a ete retire
* 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')]
#[Groups(['me:read'])]
private ?SiteInterface $currentSite = null;
#[ORM\Column]
#[AuditIgnore]
private ?string $password = null;
#[Groups(['user:write'])]
#[AuditIgnore]
private ?string $plainPassword = null;
#[ORM\Column(type: 'datetime_immutable')]
@@ -363,7 +379,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
}
/**
* @return Collection<int, Site>
* @return Collection<int, SiteInterface>
*/
public function getSites(): Collection
{
@@ -377,7 +393,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* session Doctrine (cf. ticket 2 review point #1).
*
* 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
{

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\Pagination;
use ApiPlatform\State\Pagination\PaginatorInterface;
use ArrayIterator;
use IteratorAggregate;
use Traversable;
/**
* Paginator pour resources alimentees par DBAL (pas par Doctrine ORM).
*
* Implemente PaginatorInterface : API Platform l'introspecte pour generer
* automatiquement la section `hydra:view` (first / next / previous / last)
* dans la reponse JSON-LD. Aucun calcul manuel de liens.
*
* @template T of object
*
* @implements PaginatorInterface<T>
*/
final readonly class DbalPaginator implements PaginatorInterface, IteratorAggregate
{
/**
* @param list<T> $items Items deja decoupes sur la page courante
* @param int $currentPage Page courante (1-indexee)
* @param int $itemsPerPage Limite appliquee a la requete SQL
* @param int $totalItems Resultat du COUNT(*) sans limite
*/
public function __construct(
private array $items,
private int $currentPage,
private int $itemsPerPage,
private int $totalItems,
) {}
public function getCurrentPage(): float
{
return (float) $this->currentPage;
}
public function getLastPage(): float
{
if ($this->itemsPerPage <= 0) {
return 1.0;
}
return (float) max(1, (int) ceil($this->totalItems / $this->itemsPerPage));
}
public function getItemsPerPage(): float
{
return (float) $this->itemsPerPage;
}
public function getTotalItems(): float
{
return (float) $this->totalItems;
}
public function count(): int
{
return count($this->items);
}
/**
* @return Traversable<int, T>
*/
public function getIterator(): Traversable
{
return new ArrayIterator($this->items);
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogEntityTypesProvider;
/**
* Retourne la liste des valeurs distinctes de `entity_type` presentes dans
* `audit_log`, pour alimenter le filtre multi-selection cote front (journal
* d'audit). La liste evolue automatiquement avec les nouvelles entites
* `#[Auditable]` au fil des ecritures.
*/
#[ApiResource(
shortName: 'AuditLogEntityTypes',
operations: [
new Get(
uriTemplate: '/audit-log-entity-types',
security: "is_granted('core.audit_log.view')",
provider: AuditLogEntityTypesProvider::class,
),
],
)]
final class AuditLogEntityTypesResource
{
/** @param list<string> $entityTypes */
public function __construct(
public readonly string $id = 'entity-types',
public readonly array $entityTypes = [],
) {}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Core\Application\DTO\AuditLogOutput;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogProvider;
/**
* Resource API Platform en lecture seule sur le journal d'audit.
*
* Aucune operation d'ecriture exposee (POST/PUT/PATCH/DELETE -> 405)
* conformement au caractere append-only de la table `audit_log`.
*
* La resource est un simple porteur de metadonnees #[ApiResource] ; le
* provider lit via DBAL et retourne directement des instances du DTO
* `AuditLogOutput` (declare via `output:`). La table n'est pas geree par
* l'ORM : aucune entite Doctrine n'est necessaire ici.
*
* Filtres query-param supportes par le provider :
* ?entity_type=core.User
* ?entity_id=42
* ?action=update
* ?performed_by=admin
* ?performed_at[after]=2026-04-01T00:00:00Z
* ?performed_at[before]=2026-04-30T23:59:59Z
*
* La pagination est assuree par le provider via DbalPaginator (implementant
* ApiPlatform\State\Pagination\PaginatorInterface), ce qui genere
* automatiquement hydra:view — aucune construction manuelle.
*/
#[ApiResource(
shortName: 'AuditLog',
operations: [
new GetCollection(
uriTemplate: '/audit-logs',
paginationItemsPerPage: 30,
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 50,
security: "is_granted('core.audit_log.view')",
provider: AuditLogProvider::class,
),
new Get(
uriTemplate: '/audit-logs/{id}',
requirements: ['id' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'],
security: "is_granted('core.audit_log.view')",
provider: AuditLogProvider::class,
),
],
output: AuditLogOutput::class,
)]
final class AuditLogResource {}

View File

@@ -13,7 +13,7 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* @implements ProcessorInterface<User, User>
*/
class UserPasswordHasherProcessor implements ProcessorInterface
final class UserPasswordHasherProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]

View File

@@ -9,11 +9,13 @@ use ApiPlatform\State\ProcessorInterface;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\PersistentCollection;
use LogicException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -51,12 +53,31 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
*/
final class UserRbacProcessor implements ProcessorInterface
{
/**
* Mapping cle-payload → (property-path PHP, accesseur, setter utilise pour
* reattacher les items lors de la restauration). Permet au gardefou
* anti-ecrasement de savoir quelles collections restaurer si elles sont
* absentes du payload JSON.
*
* Note : la cle JSON "roles" correspond a la propriete PHP `rbacRoles`
* (renommee via #[SerializedName] pour eviter la collision avec
* UserInterface::getRoles()).
*
* @var array<string, array{getter: string, remover: string, adder: string}>
*/
private const array COLLECTION_MAP = [
'roles' => ['getter' => 'getRbacRoles', 'remover' => 'removeRbacRole', 'adder' => 'addRbacRole'],
'directPermissions' => ['getter' => 'getDirectPermissions', 'remover' => 'removeDirectPermission', 'adder' => 'addDirectPermission'],
'sites' => ['getter' => 'getSites', 'remover' => 'removeSite', 'adder' => 'addSite'],
];
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
private readonly AdminHeadcountGuardInterface $adminHeadcountGuard,
private readonly RequestStack $requestStack,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
@@ -130,6 +151,20 @@ final class UserRbacProcessor implements ProcessorInterface
$originalCurrentSiteId,
&$result,
): void {
// Garde anti-ecrasement (defense in depth) : PATCH merge-patch+json impose
// que les cles absentes du payload ne mutent PAS les proprietes
// correspondantes. La denormalisation API Platform ne respecte pas cet
// invariant pour les collections ManyToMany — elle reinstancie une
// ArrayCollection vide des que la cle n'est pas presente. Sans cette
// garde, un client qui PATCHe juste `{ "isAdmin": true }` verrait toutes
// ses roles/directPermissions/sites detruits.
//
// Execute dans la transaction (et non avant) : garantit que le snapshot
// Doctrine lu pour restauration reflete le meme etat BDD que celui sur
// lequel le persist va operer. Evite toute fenetre de race entre la
// lecture du snapshot et le flush.
$this->restoreAbsentCollections($data);
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
// Garde coherence currentSite (ticket 2 module Sites).
@@ -180,4 +215,89 @@ final class UserRbacProcessor implements ProcessorInterface
$this->entityManager->flush();
}
}
/**
* Pour chaque collection RBAC (roles, directPermissions, sites) absente du
* payload JSON, restaure l'etat d'origine a partir du snapshot Doctrine et
* marque la collection comme non-dirty. Idempotent : si la cle est presente
* dans le payload, no-op (la denormalisation fait foi).
*
* Cas d'usage : un client qui PATCHe partiellement (`{ "isAdmin": true }`)
* ne doit pas voir ses autres collections reinitialisees. API Platform
* reinstancie par defaut une collection vide pour les cles absentes, ce
* qui casse la semantique de merge-patch+json.
*
* Pas de fallback si la collection d'origine n'est pas une PersistentCollection
* (ex: User fraichement construit) : dans ce cas aucune restauration n'est
* possible puisqu'il n'y a pas d'etat persiste a restaurer.
*/
private function restoreAbsentCollections(User $user): void
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return;
}
$rawBody = $request->getContent();
if ('' === $rawBody) {
return;
}
/** @var null|array<string, mixed> $payload */
$payload = json_decode($rawBody, true);
if (!is_array($payload)) {
return;
}
foreach (self::COLLECTION_MAP as $jsonKey => $accessors) {
// La garde ne doit sauter la restauration que si le payload fournit
// un VRAI tableau pour cette cle. Un `null`, un scalaire ou un autre
// type doivent etre traites comme "cle absente" : sinon un payload
// `{"sites": null}` contourne la restauration et laisse API Platform
// vider la collection silencieusement (bypass de la garde).
if (array_key_exists($jsonKey, $payload) && is_array($payload[$jsonKey])) {
continue;
}
/** @var Collection<int, object> $currentCollection */
$currentCollection = $user->{$accessors['getter']}();
if (!$currentCollection instanceof PersistentCollection) {
continue;
}
// Force l'initialisation LAZY avant de lire le snapshot : pour une
// association fetch=LAZY (ex: User::$sites), la PersistentCollection
// existe mais son snapshot est vide tant que la collection n'a pas
// ete materialisee. Sans cet init, `getSnapshot()` renvoie `[]` et
// la boucle de restauration ci-dessous appelle `remover()` sur
// chaque item charge par `toArray()` → **vide silencieusement la
// collection** au lieu de la preserver. Idempotent si deja initialisee.
if (!$currentCollection->isInitialized()) {
$currentCollection->initialize();
}
// Snapshot = etat charge depuis la BDD avant denormalisation.
// On restaure en retirant les items actuels et en ajoutant les
// originaux via l'adder/remover pour que les collections inverses
// (ex: Site::users) restent coherentes.
$snapshot = $currentCollection->getSnapshot();
foreach ($currentCollection->toArray() as $currentItem) {
if (!in_array($currentItem, $snapshot, true)) {
$user->{$accessors['remover']}($currentItem);
}
}
foreach ($snapshot as $originalItem) {
if (!$currentCollection->contains($originalItem)) {
$user->{$accessors['adder']}($originalItem);
}
}
// Marquer comme non-dirty pour que Doctrine ne detecte pas de diff
// et n'emette pas de requete UPDATE inutile sur la table de jointure.
$currentCollection->takeSnapshot();
}
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Core\Infrastructure\ApiPlatform\Resource\AuditLogEntityTypesResource;
use Doctrine\DBAL\Connection;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider DBAL : SELECT DISTINCT entity_type FROM audit_log.
*
* @implements ProviderInterface<AuditLogEntityTypesResource>
*/
final readonly class AuditLogEntityTypesProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'doctrine.dbal.default_connection')]
private Connection $connection,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AuditLogEntityTypesResource
{
/** @var list<string> $types */
$types = $this->connection
->executeQuery('SELECT DISTINCT entity_type FROM audit_log ORDER BY entity_type ASC')
->fetchFirstColumn()
;
return new AuditLogEntityTypesResource(entityTypes: $types);
}
}

View File

@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Core\Application\DTO\AuditLogOutput;
use App\Module\Core\Infrastructure\ApiPlatform\Pagination\DbalPaginator;
use DateTimeImmutable;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Provider API Platform pour la resource AuditLog.
*
* Lit la table `audit_log` via DBAL (pas d'entite ORM). Retourne soit :
* - une instance unique d'AuditLogOutput (operation Get) ;
* - un DbalPaginator de AuditLogOutput (operation GetCollection).
*
* Le paginator implementant PaginatorInterface laisse API Platform generer
* automatiquement la section `hydra:view` : aucune manipulation manuelle.
*
* Connexion DBAL : `default` (lecture — aucun besoin de la connexion `audit`
* reservee a l'ecriture hors transaction ORM).
*/
final readonly class AuditLogProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'doctrine.dbal.default_connection')]
private Connection $connection,
private Pagination $pagination,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AuditLogOutput|DbalPaginator|null
{
if (!$operation instanceof CollectionOperationInterface) {
return $this->provideItem((string) $uriVariables['id']);
}
return $this->provideCollection($operation, $context);
}
private function provideItem(string $id): ?AuditLogOutput
{
/** @var array<string, mixed>|false $row */
$row = $this->connection->fetchAssociative(
'SELECT id, entity_type, entity_id, action, changes, performed_by, performed_at, ip_address, request_id
FROM audit_log WHERE id = :id',
['id' => $id],
);
if (false === $row) {
return null;
}
return $this->hydrate($row);
}
/**
* @param array<string, mixed> $context
*/
private function provideCollection(Operation $operation, array $context): DbalPaginator
{
// `page` brut peut etre <= 0 (parametre client) → OFFSET negatif → 500 PG
// (`SQLSTATE[22023] OFFSET must not be negative`). API Platform clampe
// `itemsPerPage` au max de la resource mais pas `page` ; on impose un
// minimum a 1 cote provider.
$page = max(1, $this->pagination->getPage($context));
$itemsPerPage = $this->pagination->getLimit($operation, $context);
$offset = ($page - 1) * $itemsPerPage;
$filters = $this->extractFilters($context['filters'] ?? []);
$dataQuery = $this->buildBaseQuery()
->select('id', 'entity_type', 'entity_id', 'action', 'changes', 'performed_by', 'performed_at', 'ip_address', 'request_id')
->orderBy('performed_at', 'DESC')
// Tie-breaker sur `id` (UUID v7 monotone) : garantit un tri
// totalement deterministe quand plusieurs lignes partagent la
// meme timestamp (ex: batch fixture, bulk flush < 1µs).
->addOrderBy('id', 'DESC')
->setFirstResult($offset)
->setMaxResults($itemsPerPage)
;
$countQuery = $this->buildBaseQuery()->select('COUNT(*)');
$this->applyFilters($dataQuery, $filters);
$this->applyFilters($countQuery, $filters);
/** @var list<array<string, mixed>> $rows */
$rows = $dataQuery->executeQuery()->fetchAllAssociative();
$totalItems = (int) $countQuery->executeQuery()->fetchOne();
$items = array_map(fn (array $row) => $this->hydrate($row), $rows);
return new DbalPaginator($items, $page, $itemsPerPage, $totalItems);
}
private function buildBaseQuery(): QueryBuilder
{
return $this->connection->createQueryBuilder()->from('audit_log');
}
/**
* @param array<string, mixed> $raw
*
* @return array{entity_type?: list<string>|string, entity_id?: string, action?: string, performed_by?: string, performed_at_after?: string, performed_at_before?: string}
*/
private function extractFilters(array $raw): array
{
$filters = [];
// `entity_type` accepte soit une chaine, soit une liste (query syntax
// `entity_type[]=core.User&entity_type[]=core.Role`) pour le filtre
// multi-selection cote front. On normalise en list<string> non-vide.
if (isset($raw['entity_type'])) {
if (is_string($raw['entity_type']) && '' !== $raw['entity_type']) {
$filters['entity_type'] = $raw['entity_type'];
} elseif (is_array($raw['entity_type'])) {
$cleaned = array_values(array_filter(
$raw['entity_type'],
static fn ($v): bool => is_string($v) && '' !== $v,
));
if ([] !== $cleaned) {
$filters['entity_type'] = $cleaned;
}
}
}
foreach (['entity_id', 'performed_by'] as $key) {
if (isset($raw[$key]) && is_string($raw[$key]) && '' !== $raw[$key]) {
$filters[$key] = $raw[$key];
}
}
// `action` : whitelist stricte. Un input hors-liste provoquait avant
// un simple match vide (resultat 0 ligne) mais permettait d'incrementer
// le log applicatif a chaque variation ; on rejette en 400 explicite.
if (isset($raw['action']) && is_string($raw['action']) && '' !== $raw['action']) {
if (!in_array($raw['action'], ['create', 'update', 'delete'], true)) {
throw new BadRequestHttpException(
'Filtre "action" invalide : valeurs autorisees create|update|delete.',
);
}
$filters['action'] = $raw['action'];
}
// Filtres de plage `performed_at[after]` / `performed_at[before]`.
// Sans validation, un input malforme remonte jusqu'a Postgres qui
// leve `SQLSTATE[22007]: invalid input syntax for type timestamp` →
// 500 Internal Server Error, log Monolog pollue, mauvaise UX API.
// On valide en amont et on rejette en 400 explicite.
if (isset($raw['performed_at']) && is_array($raw['performed_at'])) {
$range = $raw['performed_at'];
foreach (['after', 'before'] as $bound) {
if (!isset($range[$bound]) || !is_string($range[$bound]) || '' === $range[$bound]) {
continue;
}
if (false === strtotime($range[$bound])) {
throw new BadRequestHttpException(sprintf(
'Filtre "performed_at[%s]" invalide : date ISO 8601 attendue (ex: 2026-04-22T00:00:00Z).',
$bound,
));
}
$filters['performed_at_'.$bound] = $range[$bound];
}
}
return $filters;
}
/**
* @param array<string, list<string>|string> $filters
*/
private function applyFilters(QueryBuilder $qb, array $filters): void
{
if (isset($filters['entity_type'])) {
if (is_array($filters['entity_type'])) {
$qb->andWhere('entity_type IN (:entity_types)')
->setParameter('entity_types', $filters['entity_type'], ArrayParameterType::STRING)
;
} else {
$qb->andWhere('entity_type = :entity_type')->setParameter('entity_type', $filters['entity_type']);
}
}
if (isset($filters['entity_id'])) {
$qb->andWhere('entity_id = :entity_id')->setParameter('entity_id', $filters['entity_id']);
}
if (isset($filters['action'])) {
$qb->andWhere('action = :action')->setParameter('action', $filters['action']);
}
if (isset($filters['performed_by'])) {
// Recherche contains insensible a la casse pour matcher "adm" → "admin".
// On echappe `%`, `_` et `\` saisis par l'utilisateur pour qu'ils soient
// interpretes comme caracteres litteraux (sinon `%` matche tout, `_`
// matche n'importe quel caractere). Pas de clause ESCAPE : `\` est
// deja le caractere d'echappement LIKE par defaut en PostgreSQL.
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $filters['performed_by']);
$qb->andWhere('performed_by ILIKE :performed_by')
->setParameter('performed_by', '%'.$escaped.'%')
;
}
if (isset($filters['performed_at_after'])) {
$qb->andWhere('performed_at >= :performed_at_after')->setParameter('performed_at_after', $filters['performed_at_after']);
}
if (isset($filters['performed_at_before'])) {
$qb->andWhere('performed_at <= :performed_at_before')->setParameter('performed_at_before', $filters['performed_at_before']);
}
}
/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): AuditLogOutput
{
/** @var string $rawChanges */
$rawChanges = $row['changes'] ?? '{}';
/** @var array<string, mixed> $changes */
$changes = is_array($rawChanges) ? $rawChanges : json_decode((string) $rawChanges, true, 512, JSON_THROW_ON_ERROR);
return new AuditLogOutput(
id: (string) $row['id'],
entityType: (string) $row['entity_type'],
entityId: (string) $row['entity_id'],
action: (string) $row['action'],
changes: $changes,
performedBy: (string) $row['performed_by'],
performedAt: new DateTimeImmutable((string) $row['performed_at']),
ipAddress: null !== $row['ip_address'] ? (string) $row['ip_address'] : null,
requestId: null !== $row['request_id'] ? (string) $row['request_id'] : null,
);
}
}

View File

@@ -11,7 +11,7 @@ use Symfony\Bundle\SecurityBundle\Security;
/**
* @implements ProviderInterface<object>
*/
class MeProvider implements ProviderInterface
final class MeProvider implements ProviderInterface
{
public function __construct(
private readonly Security $security,

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Audit;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Types\Types;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Uid\Uuid;
/**
* Service bas-niveau responsable de l'ecriture dans la table `audit_log`.
*
* Utilise une connexion DBAL dediee `audit` (meme DSN que `default`, service
* separe) pour ecrire hors de la transaction ORM : indispensable pour que
* les lignes d'audit survivent meme si le flush applicatif est rollback,
* et pour eviter tout entanglement transactionnel en batch (fixtures).
*
* Les cles sensibles (password, plainPassword, token, secret) sont filtrees
* en defense-in-depth meme si les entites declarent deja ces proprietes
* #[AuditIgnore].
*
* Erreur silencieuse : en cas d'echec SQL, on lance pas l'exception plus
* haut — l'audit ne doit jamais faire crasher un flux metier. Le listener
* wrappe l'appel dans un try/catch + logger (cf. AuditListener).
*/
final class AuditLogWriter
{
/** @var list<string> cles systematiquement strippees du payload `changes` */
private const array SENSITIVE_KEYS = ['password', 'plainPassword', 'token', 'secret'];
public function __construct(
#[Autowire(service: 'doctrine.dbal.audit_connection')]
private readonly Connection $connection,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly RequestIdProvider $requestIdProvider,
) {}
/**
* Ecrit une ligne d'audit.
*
* @param string $entityType Format "module.Entity" (ex: "core.User")
* @param string $entityId ID de l'entite (int ou UUID serialise)
* @param string $action create|update|delete
* @param array<string, mixed> $changes Payload JSON (filtre des cles sensibles)
*/
public function log(
string $entityType,
string $entityId,
string $action,
array $changes,
): void {
$filteredChanges = $this->stripSensitive($changes);
$this->connection->insert('audit_log', [
'id' => Uuid::v7()->toRfc4122(),
'entity_type' => $entityType,
'entity_id' => $entityId,
'action' => $action,
'changes' => $filteredChanges,
'performed_by' => $this->security->getUser()?->getUserIdentifier() ?? 'system',
'performed_at' => new DateTimeImmutable('now', new DateTimeZone('UTC')),
'ip_address' => $this->requestStack->getCurrentRequest()?->getClientIp(),
'request_id' => $this->requestIdProvider->getRequestId(),
], [
// Types de conversion DBAL : UUID natif PG + jsonb + datetimetz.
// Sans 'id' => GUID, DBAL passerait un varchar et Postgres ferait
// un cast implicite — ca marche mais l'intention est floue.
'id' => Types::GUID,
'changes' => Types::JSON,
'performed_at' => Types::DATETIMETZ_IMMUTABLE,
]);
}
/**
* Supprime recursivement les cles sensibles du payload.
*
* Utile pour les snapshots complets (create/delete) ou les changes
* d'update : le listener prefiltre deja mais on garde cette garde
* en defense-in-depth si un appelant direct oublie `#[AuditIgnore]`.
*
* Recursion : parcourt les sous-tableaux (ex: changes structures
* `{field: {old, new}}`, snapshots avec relations imbriquees, ou
* payload arbitraire pousse par un appelant direct).
*
* @param array<string, mixed> $data
*
* @return array<string, mixed>
*/
private function stripSensitive(array $data): array
{
foreach ($data as $key => $value) {
if (in_array($key, self::SENSITIVE_KEYS, true)) {
unset($data[$key]);
continue;
}
if (is_array($value)) {
$data[$key] = $this->stripSensitive($value);
}
}
return $data;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Audit;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Uid\Uuid;
/**
* Fournit un identifiant de requete HTTP (UUID v4) partage par toutes les
* lignes d'audit produites au cours d'une meme requete principale.
*
* Utilite : retrouver d'un seul coup d'oeil toutes les ecritures liees a un
* meme appel utilisateur (ex: PATCH qui cascade des updates sur plusieurs
* entites). Null en CLI (fixtures, commandes batch).
*
* Service singleton (scope container par defaut) — un unique UUID est
* genere au kernel.request principal et reutilise pour toute la requete.
*/
final class RequestIdProvider
{
private ?string $requestId = null;
#[AsEventListener(event: 'kernel.request')]
public function onKernelRequest(RequestEvent $event): void
{
// Ignorer les sub-requests (ESI, forward interne) pour ne pas
// ecraser l'UUID de la requete principale.
if (!$event->isMainRequest()) {
return;
}
$this->requestId = Uuid::v4()->toRfc4122();
}
public function getRequestId(): ?string
{
return $this->requestId;
}
}

View File

@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Console;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles;
use App\Shared\Domain\Contract\SiteProviderInterface;
use Doctrine\ORM\EntityManagerInterface;
use RuntimeException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Seed dedie aux tests E2E Playwright (frontend/tests/e2e).
*
* Cree 6 personas (e2e.*) qui couvrent les cases nominales de la matrice
* RBAC : super-admin, user-full, user-readonly, user-users-only,
* user-audit-only, user-nothing. Cette liste est la replique back du
* fichier `frontend/tests/e2e/_fixtures/personas.ts` — si tu modifies
* l'une des deux, met a jour l'autre.
*
* Idempotent : supprime les users prefixes `e2e.` avant de les recreer.
* Ne touche PAS aux fixtures dev (admin/alice/bob) ni aux sites.
*
* Pre-requis : `bin/console app:sync-permissions` doit avoir tourne pour
* que les permissions soient en base. La commande echoue en erreur explicite
* si une permission attendue est absente du catalogue.
*/
#[AsCommand(
name: 'app:seed-e2e',
description: 'Seed les 6 personas utilises par les tests E2E Playwright.',
)]
final class SeedE2ECommand extends Command
{
private const string SHARED_PASSWORD = 'e2e-secret';
private const string E2E_USERNAME_PREFIX = 'e2e.';
private const string DEFAULT_SITE_NAME = 'Chatellerault';
public function __construct(
private readonly EntityManagerInterface $em,
private readonly UserRepositoryInterface $userRepository,
private readonly RoleRepositoryInterface $roleRepository,
private readonly PermissionRepositoryInterface $permissionRepository,
private readonly SiteProviderInterface $siteProvider,
private readonly UserPasswordHasherInterface $passwordHasher,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
// Garde-fou : cette commande cree un compte admin avec un mot de passe
// hardcode. Elle ne doit JAMAIS tourner hors dev/test, meme si le
// fichier se retrouve embarque dans une image prod par accident (le
// .dockerignore a la racine est la premiere ligne de defense).
$env = $_SERVER['APP_ENV'] ?? 'prod';
if (!in_array($env, ['dev', 'test'], true)) {
$io->error(sprintf('app:seed-e2e est refuse en environnement "%s". Autorise uniquement en dev/test.', $env));
return Command::FAILURE;
}
$userRole = $this->roleRepository->findByCode(SystemRoles::USER_CODE);
if (null === $userRole) {
$io->error(sprintf(
'Le role systeme "%s" est introuvable. Lance les migrations + fixtures ou `app:sync-permissions` avant ce seed.',
SystemRoles::USER_CODE,
));
return Command::FAILURE;
}
$defaultSite = $this->siteProvider->findByName(self::DEFAULT_SITE_NAME);
// Pas de fail fatal si le site manque : les tests sidebar/login
// n'en dependent pas. Les tests sites-scope-bypass (a venir) le feront.
if (null === $defaultSite) {
$io->note(sprintf(
'Site "%s" absent : les personas seront crees sans site. Lance `make fixtures` si tu as besoin des sites.',
self::DEFAULT_SITE_NAME,
));
}
$this->wipeExistingE2EUsers($io);
foreach ($this->personasDefinition() as $persona) {
$user = new User();
$user->setUsername($persona['username']);
$user->setPassword($this->passwordHasher->hashPassword($user, self::SHARED_PASSWORD));
$user->setIsAdmin($persona['isAdmin']);
$user->addRbacRole($userRole);
foreach ($persona['permissions'] as $code) {
$permission = $this->permissionRepository->findByCode($code);
if (null === $permission) {
throw new RuntimeException(sprintf(
'Permission "%s" introuvable en base. Lance `app:sync-permissions` avant `app:seed-e2e`.',
$code,
));
}
$user->addDirectPermission($permission);
}
if (null !== $defaultSite && 'e2e.user-nothing' !== $persona['username']) {
// user-nothing reste sans site pour pouvoir tester un flow
// "aucune permission et aucun site".
$user->addSite($defaultSite);
$user->setCurrentSite($defaultSite);
}
$this->userRepository->save($user);
$io->text(sprintf(
' - %s (admin=%s, permissions=%d)',
$persona['username'],
$persona['isAdmin'] ? 'oui' : 'non',
count($persona['permissions']),
));
}
$io->success(sprintf('%d personas E2E seedes.', count($this->personasDefinition())));
return Command::SUCCESS;
}
private function wipeExistingE2EUsers(SymfonyStyle $io): void
{
$removed = 0;
foreach ($this->personasDefinition() as $persona) {
$existing = $this->userRepository->findByUsername($persona['username']);
if (null === $existing) {
continue;
}
$this->em->remove($existing);
++$removed;
}
if ($removed > 0) {
$this->em->flush();
$io->text(sprintf('Nettoyage : %d users E2E supprimes.', $removed));
}
}
/**
* Liste des personas — source back, miroir de
* `frontend/tests/e2e/_fixtures/personas.ts`.
*
* @return list<array{username: string, isAdmin: bool, permissions: list<string>}>
*/
private function personasDefinition(): array
{
return [
[
'username' => self::E2E_USERNAME_PREFIX.'super-admin',
'isAdmin' => true,
'permissions' => [],
],
[
'username' => self::E2E_USERNAME_PREFIX.'user-full',
'isAdmin' => false,
'permissions' => [
'core.users.view',
'core.users.manage',
'core.roles.view',
'core.roles.manage',
'core.audit_log.view',
'sites.view',
'sites.manage',
'sites.bypass_scope',
],
],
[
'username' => self::E2E_USERNAME_PREFIX.'user-readonly',
'isAdmin' => false,
'permissions' => [
'core.users.view',
'core.roles.view',
'core.audit_log.view',
'sites.view',
],
],
[
'username' => self::E2E_USERNAME_PREFIX.'user-users-only',
'isAdmin' => false,
'permissions' => ['core.users.view', 'core.users.manage'],
],
[
'username' => self::E2E_USERNAME_PREFIX.'user-audit-only',
'isAdmin' => false,
'permissions' => ['core.audit_log.view'],
],
[
'username' => self::E2E_USERNAME_PREFIX.'user-nothing',
'isAdmin' => false,
'permissions' => [],
],
];
}
}

View File

@@ -8,9 +8,9 @@ use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
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\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SiteProviderInterface;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
@@ -39,7 +39,7 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly RoleRepositoryInterface $roleRepository,
private readonly SiteRepositoryInterface $siteRepository,
private readonly SiteProviderInterface $siteProvider,
) {}
/**
@@ -135,9 +135,9 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
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) {
throw new RuntimeException(sprintf(

View File

@@ -0,0 +1,513 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Infrastructure\Audit\AuditLogWriter;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\UnitOfWork;
use Psr\Log\LoggerInterface;
use ReflectionClass;
use ReflectionProperty;
use Throwable;
/**
* Listener Doctrine qui produit les lignes d'audit pour les entites portant
* l'attribut #[Auditable].
*
* Pipeline en deux temps :
* 1. onFlush : on traverse UnitOfWork (insertions / updates / deletions) et
* on capture les changements en memoire. Aucune ecriture SQL cote audit
* a ce stade pour ne pas interferer avec la transaction ORM en cours.
* 2. postFlush : on ecrit via AuditLogWriter (connexion DBAL dediee).
*
* Pattern swap-and-clear dans postFlush :
* - on copie localement la liste des evenements ;
* - on vide la propriete pendingLogs immediatement ;
* - on itere la copie.
* Pourquoi : si une ecriture audit declenchait un flush re-entrant (cas rare,
* ex: callback listener externe), l'etat de pendingLogs serait deja nettoye —
* pas de double insertion, pas de boucle infinie.
*
* Erreurs silencieuses : un INSERT audit qui echoue est logue en error mais
* jamais propage. Acceptable pour un CRM interne ; a reconsiderer si besoin
* de garantie forte (dead-letter queue, retry).
*
* Collections (OneToMany / ManyToMany) :
* - Les modifications de collections sont tracees via
* `getScheduledCollectionUpdates()` et reportees comme un changement
* `{fieldName: {added: [ids], removed: [ids]}}` dans le changeset de
* l'entite proprietaire.
* - Si l'entite proprietaire est deja scheduled pour insertion, la diff
* est merge dans le snapshot create (en tant que liste d'IDs initiaux).
* - Si l'entite proprietaire est scheduled pour deletion, les collections
* associees sont ignorees (deja couvertes par le snapshot delete).
*
* Limitations connues :
* - Les ManyToOne sont tracees par ID (null-safe via `?->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<class-string, bool>
*/
private array $auditableCache = [];
/**
* Cache par FQCN : liste des noms de proprietes ignorees (#[AuditIgnore]).
*
* @var array<class-string, list<string>>
*/
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<array{entity: object, metadata: ClassMetadata, entityType: string, action: string, changes: array<string, mixed>, 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<string, array{old: mixed, new: mixed}>
*/
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<string, mixed>
*/
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<string>
*/
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\\\(?<module>[^\\\]+)\\\.+\\\(?<entity>[^\\\]+)$#', $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;
}
}

View File

@@ -12,6 +12,7 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\SiteInterface;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -64,6 +65,7 @@ use Symfony\Component\Validator\Constraints as Assert;
)]
#[ORM\Entity(repositoryClass: DoctrineSiteRepository::class)]
#[ORM\Table(name: 'site')]
#[Auditable]
#[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])]
#[ORM\HasLifecycleCallbacks]
#[UniqueEntity(fields: ['name'], message: 'Un site avec ce nom existe deja.')]

View File

@@ -5,8 +5,9 @@ declare(strict_types=1);
namespace App\Module\Sites\Domain\Repository;
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;

View File

@@ -34,7 +34,7 @@ class DoctrineSiteRepository extends ServiceEntityRepository implements SiteRepo
*/
public function findAllOrderedByName(): array
{
/** @var list<Site> $sites */
// @var list<Site> $sites
return $this->findBy([], ['name' => 'ASC']);
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Attribute;
use Attribute;
/**
* Marqueur a poser sur une propriete d'entite pour l'exclure du tracking audit.
*
* Usage typique : champs sensibles (password, token), champs bruyants (updatedAt
* si recalcule sur chaque ecriture), champs derives. L'AuditLogWriter porte
* deja une blacklist exact-match sur les noms les plus dangereux (password,
* plainPassword, token, secret) en defense-in-depth, mais la regle de base
* reste : annoter explicitement ce qu'on ne veut pas voir trace.
*/
#[Attribute(Attribute::TARGET_PROPERTY)]
final class AuditIgnore {}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Attribute;
use Attribute;
/**
* Marqueur a poser sur une entite Doctrine pour activer le tracking audit.
*
* Emplacement dans Shared (pas dans Core) pour que tous les modules puissent
* l'utiliser sans dependance circulaire vers Core.
*
* Regle projet (cf. doc/audit-log.md) : toute entite metier DOIT porter cet
* attribut, avec #[AuditIgnore] sur les champs sensibles ou bruyants.
*/
#[Attribute(Attribute::TARGET_CLASS)]
final class Auditable {}

View File

@@ -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;
}

View File

@@ -17,7 +17,7 @@ class SidebarProvider implements ProviderInterface
/** @var list<string> */
private readonly array $activeModuleIds;
/** @var list<array{label: string, icon: string, items: list<array{label: string, to: string, icon: string, module: string, permission?: string}>}> */
/** @var list<array{label: string, icon: string, permission?: string, items: list<array{label: string, to: string, icon: string, module: string, permission?: string}>}> */
private readonly array $sidebarConfig;
public function __construct(private readonly Security $security)
@@ -47,6 +47,23 @@ class SidebarProvider implements ProviderInterface
$disabledRoutes = [];
foreach ($this->sidebarConfig as $section) {
// Gate de section (optionnel) : si la section declare une permission
// et que l'utilisateur ne la possede pas, la section entiere est
// masquee. Toutes les routes de ses items basculent dans
// `disabledRoutes` pour que le middleware front redirige toute
// navigation directe, y compris si l'item n'a pas de permission
// individuelle (la section agit comme un umbrella gate).
$sectionPermission = $section['permission'] ?? null;
if (null !== $sectionPermission && !$this->security->isGranted($sectionPermission)) {
foreach ($section['items'] ?? [] as $item) {
if (isset($item['to'])) {
$disabledRoutes[] = $item['to'];
}
}
continue;
}
$items = [];
foreach ($section['items'] ?? [] as $item) {
$isActive = in_array($item['module'] ?? null, $this->activeModuleIds, true);