From de39fe6a3e6a7542d7303bc6543a12ec59e347f5 Mon Sep 17 00:00:00 2001 From: matthieu Date: Mon, 20 Apr 2026 20:51:10 +0200 Subject: [PATCH 01/37] feat : add audit log (table, writer, listener, API, admin UI, timeline) Implemente le journal d'audit append-only sur toutes les mutations Doctrine des entites portant #[Auditable]. Couvre les 5 tickets de doc/audit-log.md : 1. Table PG audit_log (uuid PK, jsonb changes, index entity/time/performer) + AuditLogWriter (DBAL connexion dediee audit, blacklist defense-in-depth sur password/plainPassword/token/secret) + RequestIdProvider (UUID v4 par requete HTTP principale). 2. Attributs Auditable / AuditIgnore dans Shared/Domain/Attribute/ + AuditListener (onFlush capture + postFlush ecriture hors transaction ORM, pattern swap-and-clear, erreurs loguees jamais propagees). User annote. 3. API Platform read-only /api/audit-logs (permission core.audit_log.view) avec filtres entity_type / entity_id / action / performed_by / plage performed_at + DbalPaginator implementant PaginatorInterface (hydra:view genere automatiquement). 4. Page admin /admin/audit-log : tableau pagine, filtres persistes en query params, row expandable (diff + timeline de l'entite), entree sidebar avec permission. Composable useAuditLog avec resetAuditLog() auto-enregistre sur onAuthSessionCleared. 5. Composant AuditTimeline reutilisable : garde permission, lazy loading, dates relatives FR, skeleton loader. Fix connexe : phpunit.dist.xml forcait APP_ENV=dev via ce qui cablait framework.test=false et rendait test.service_container indisponible ; le JWT_PASSPHRASE ne matchait pas non plus les cles dev. Corrige en meme temps pour debloquer la suite de tests. --- CLAUDE.md | 1 + composer.json | 1 + composer.lock | 2 +- config/packages/api_platform.yaml | 3 + config/packages/doctrine.yaml | 23 +- config/reference.php | 2 +- config/sidebar.php | 7 + doc/audit-log.md | 411 ++++++++++++++++++ frontend/i18n/locales/fr.json | 47 +- .../modules/core/pages/admin/audit-log.vue | 356 +++++++++++++++ .../components/audit/AuditLogDetail.vue | 65 +++ .../shared/components/audit/AuditTimeline.vue | 204 +++++++++ frontend/shared/composables/useAuditLog.ts | 89 ++++ frontend/shared/types/index.ts | 34 ++ frontend/shared/utils/api.ts | 10 + migrations/Version20260420202749.php | 63 +++ phpunit.dist.xml | 40 ++ .../Core/Application/DTO/AuditLogOutput.php | 30 ++ src/Module/Core/CoreModule.php | 1 + src/Module/Core/Domain/Entity/User.php | 5 + .../ApiPlatform/Pagination/DbalPaginator.php | 74 ++++ .../ApiPlatform/Resource/AuditLogResource.php | 53 +++ .../State/Provider/AuditLogProvider.php | 177 ++++++++ .../Infrastructure/Audit/AuditLogWriter.php | 97 +++++ .../Audit/RequestIdProvider.php | 42 ++ .../Infrastructure/Doctrine/AuditListener.php | 336 ++++++++++++++ src/Shared/Domain/Attribute/AuditIgnore.php | 19 + src/Shared/Domain/Attribute/Auditable.php | 19 + tests/Module/Core/Api/AuditLogApiTest.php | 210 +++++++++ .../Audit/AuditLogWriterTest.php | 163 +++++++ .../Doctrine/AuditListenerTest.php | 176 ++++++++ 31 files changed, 2754 insertions(+), 6 deletions(-) create mode 100644 doc/audit-log.md create mode 100644 frontend/modules/core/pages/admin/audit-log.vue create mode 100644 frontend/shared/components/audit/AuditLogDetail.vue create mode 100644 frontend/shared/components/audit/AuditTimeline.vue create mode 100644 frontend/shared/composables/useAuditLog.ts create mode 100644 migrations/Version20260420202749.php create mode 100644 src/Module/Core/Application/DTO/AuditLogOutput.php create mode 100644 src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php create mode 100644 src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php create mode 100644 src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php create mode 100644 src/Module/Core/Infrastructure/Audit/AuditLogWriter.php create mode 100644 src/Module/Core/Infrastructure/Audit/RequestIdProvider.php create mode 100644 src/Module/Core/Infrastructure/Doctrine/AuditListener.php create mode 100644 src/Shared/Domain/Attribute/AuditIgnore.php create mode 100644 src/Shared/Domain/Attribute/Auditable.php create mode 100644 tests/Module/Core/Api/AuditLogApiTest.php create mode 100644 tests/Module/Core/Infrastructure/Audit/AuditLogWriterTest.php create mode 100644 tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 3559259..2c9e4de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -234,6 +234,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash` - Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour eviter le conflit avec API Platform `{id}` - Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux proprietes de l'entite cible - Upload fichiers : utiliser `$file->getMimeType()` (pas `getClientMimeType()`) pour valider cote serveur +- **Audit obligatoire** : toute entite (nouvelle ou existante) doit porter `#[Auditable]` (dans `Shared/Domain/Attribute/`). Les champs sensibles (password, token, secret) doivent etre annotes `#[AuditIgnore]`. Spec complete : `doc/audit-log.md` ### Frontend diff --git a/composer.json b/composer.json index a3a9b4f..ee7d9a9 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ "symfony/runtime": "8.0.*", "symfony/security-bundle": "8.0.*", "symfony/serializer": "8.0.*", + "symfony/uid": "8.0.*", "symfony/validator": "8.0.*", "symfony/yaml": "8.0.*" }, diff --git a/composer.lock b/composer.lock index 43d0aeb..6914aca 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "75f8e672f2a401290886fbcf01befd3f", + "content-hash": "65f8419b8830b250fe461933fe240a14", "packages": [ { "name": "api-platform/doctrine-common", diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index fc3d6a9..bc1233e 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -9,6 +9,9 @@ api_platform: mapping: paths: - '%kernel.project_dir%/src/Module/Core/Domain/Entity' + # Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource] + # en dehors de Domain/Entity : AuditLogResource, etc. + - '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource' formats: jsonld: ['application/ld+json'] json: ['application/json'] diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index c21cc4f..2864ce8 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -1,7 +1,15 @@ doctrine: dbal: - url: '%env(resolve:DATABASE_URL)%' - profiling_collect_backtrace: '%kernel.debug%' + # Deux connexions pointant sur le meme DSN : l'ORM utilise `default`, + # l'AuditLogWriter utilise `audit` pour ecrire hors de la transaction + # Doctrine et eviter tout entanglement transactionnel en batch. + default_connection: default + connections: + default: + url: '%env(resolve:DATABASE_URL)%' + profiling_collect_backtrace: '%kernel.debug%' + audit: + url: '%env(resolve:DATABASE_URL)%' orm: validate_xml_mapping: true naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware @@ -31,7 +39,16 @@ doctrine: when@test: doctrine: dbal: - dbname_suffix: '_test%env(default::TEST_TOKEN)%' + # Le suffixe "_test" doit etre propage aux deux connexions : l'ORM + # l'herite via `default`, l'AuditLogWriter via `audit`. Sans cela, + # la connexion `audit` ecrirait dans la base dev pendant que l'ORM + # ecrit dans la base test — divergence invisible en apparence mais + # fatale pour les tests du journal d'audit. + connections: + default: + dbname_suffix: '_test%env(default::TEST_TOKEN)%' + audit: + dbname_suffix: '_test%env(default::TEST_TOKEN)%' orm: mappings: # Entite fictive SiteAware utilisee uniquement en tests du diff --git a/config/reference.php b/config/reference.php index c909b5c..d35cc73 100644 --- a/config/reference.php +++ b/config/reference.php @@ -467,7 +467,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }, * disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true * http_client?: bool|array{ // HTTP Client configuration - * enabled?: bool|Param, // Default: true + * enabled?: bool|Param, // Default: false * max_host_connections?: int|Param, // The maximum number of connections to a single host. * default_options?: array{ * headers?: array, diff --git a/config/sidebar.php b/config/sidebar.php index c82f02d..4c14db7 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -55,6 +55,13 @@ return [ 'module' => 'sites', 'permission' => 'sites.view', ], + [ + 'label' => 'sidebar.core.audit_log', + 'to' => '/admin/audit-log', + 'icon' => 'mdi:clipboard-text-clock', + 'module' => 'core', + 'permission' => 'core.audit_log.view', + ], [ 'label' => 'sidebar.general.logout', 'to' => '/logout', diff --git a/doc/audit-log.md b/doc/audit-log.md new file mode 100644 index 0000000..7838048 --- /dev/null +++ b/doc/audit-log.md @@ -0,0 +1,411 @@ +# Audit Log — Specification technique + +## Objectif + +Tracer l'historique de toutes les modifications BDD dans une table `audit_log` append-only. L'audit est opt-in via l'attribut `#[Auditable]` sur les entites, expose en lecture seule via API Platform (permission RBAC `core.audit_log.view`), et visualise dans le frontend via une page dediee et un composant timeline reutilisable. + +**Regle projet** : toute entite (nouvelle ou existante) doit etre annotee `#[Auditable]` avec `#[AuditIgnore]` sur les champs sensibles. L'audit n'est pas optionnel — il est obligatoire sur toutes les entites metier. + +--- + +## Architecture + +``` +src/ + Shared/ + Domain/ + Attribute/ + Auditable.php # Attribut classe — active le tracking + AuditIgnore.php # Attribut propriete — exclut un champ + Module/ + Core/ + CoreModule.php # + permission core.audit_log.view + Application/ + DTO/ + AuditLogOutput.php # DTO lecture seule + Infrastructure/ + Audit/ + AuditLogWriter.php # Ecrit via DBAL (pas Doctrine ORM) + RequestIdProvider.php # UUID v4 par requete HTTP + Doctrine/ + AuditListener.php # Listener onFlush/postFlush + Migrations/ # (migration dans migrations/ racine — cf. bug tri FQCN) + ApiPlatform/ + Resource/ + AuditLogResource.php # ApiResource read-only + State/ + Provider/ + AuditLogProvider.php # Provider DBAL + +frontend/ + shared/ + composables/ + useAuditLog.ts # Composable partage (page + timeline) + components/ + audit/ + AuditTimeline.vue # Timeline verticale reutilisable + types/ + index.ts # + AuditLogEntry, AuditLogFilters, HydraView + utils/ + api.ts # + support hydra:view pagination + modules/ + core/ + pages/ + admin/ + audit-log.vue # Page globale admin +``` + +--- + +## Table PostgreSQL `audit_log` + +Table non geree par Doctrine ORM (pas d'entite). Ecriture via DBAL uniquement pour eviter la recursion des listeners. + +### Schema + +| Colonne | Type | Contrainte | Description | +|---------|------|-----------|-------------| +| `id` | uuid | PK | UUID v7 genere en PHP (`Uuid::v7()->toRfc4122()`) — type natif PG (16 octets vs 36 en varchar) | +| `entity_type` | varchar(100) | NOT NULL | Format `module.Entity` (ex: `core.User`, `commercial.Client`) — evite les collisions inter-modules | +| `entity_id` | varchar(64) | NOT NULL | ID de l'entite (supporte int et UUID) | +| `action` | varchar(10) | NOT NULL | `create`, `update`, `delete` | +| `changes` | jsonb | NOT NULL DEFAULT '{}' | Changements (format selon action) | +| `performed_by` | varchar(100) | NOT NULL | Username denormalise (survit a la suppression du user) | +| `performed_at` | timestamptz | NOT NULL | Horodatage de l'action | +| `ip_address` | varchar(45) | NULL | Adresse IP (null en CLI) | +| `request_id` | varchar(36) | NULL | UUID v4 par requete HTTP (null en CLI) | + +### Index + +- `idx_audit_entity_time` : `(entity_type, entity_id, performed_at)` — recherche par entite +- `idx_audit_performer` : `(performed_by, performed_at)` — recherche par utilisateur +- `idx_audit_time` : `(performed_at)` — tri chronologique global + +### Regles + +- **Append-only** : pas d'UPDATE, pas de DELETE +- **Colonnes en minuscules** (convention PostgreSQL du projet) +- **Champs sensibles exclus** : `password`, `plainPassword`, `token`, `secret` ne doivent jamais apparaitre dans `changes` +- **`performed_by` denormalise** : string, pas FK — le nom persiste meme si l'utilisateur est supprime +- **Migration** : dans `migrations/` (namespace racine `DoctrineMigrations`) a cause du bug de tri alphabetique FQCN de Doctrine Migrations 3.x entre namespaces + +--- + +## Composants backend + +### `AuditLogWriter` + +**Emplacement** : `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php` + +Service responsable de l'ecriture dans `audit_log` via `Connection::executeStatement()`. + +**Dependances** : +- `Doctrine\DBAL\Connection` — connexion DBAL dediee `audit` (meme DSN, service separe) pour eviter l'entanglement transactionnel avec l'ORM. Config : `doctrine.dbal.connections.audit` dans `doctrine.yaml`. Injection via `#[Autowire(service: 'doctrine.dbal.audit_connection')]`. +- `Symfony\Bundle\SecurityBundle\Security` +- `Symfony\Component\HttpFoundation\RequestStack` +- `RequestIdProvider` + +**Methode principale** : +```php +public function log( + string $entityType, + string $entityId, + string $action, + array $changes, +): void +``` + +**Comportement** : +- Genere `id` via `Uuid::v7()->toRfc4122()` +- `performed_by` = `$security->getUser()?->getUserIdentifier() ?? 'system'` +- `ip_address` = `$requestStack->getCurrentRequest()?->getClientIp()` +- `request_id` = `$requestIdProvider->getRequestId()` +- `performed_at` = `new \DateTimeImmutable('now', new \DateTimeZone('UTC'))` +- Filtre les cles sensibles (`password`, `plainPassword`, `token`, `secret`) de `$changes` +- INSERT SQL brut via DBAL + +**Necessite** : `composer require symfony/uid` + +### `RequestIdProvider` + +**Emplacement** : `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php` + +Service singleton qui genere un UUID v4 unique par requete HTTP principale. + +**Comportement** : +- Ecoute `kernel.request` via `#[AsEventListener]` +- Ignore les sub-requests : `if (!$event->isMainRequest()) return;` +- Genere `Uuid::v4()->toRfc4122()` a chaque requete principale +- Expose `getRequestId(): ?string` (null en CLI) + +### Attributs `#[Auditable]` et `#[AuditIgnore]` + +**Emplacement** : `src/Shared/Domain/Attribute/` (dans Shared, pas Core — tous les modules doivent y acceder) + +- `#[Auditable]` : attribut de classe, marqueur vide. Active le tracking sur l'entite. +- `#[AuditIgnore]` : attribut de propriete, marqueur vide. Exclut un champ du tracking. + +### `AuditListener` + +**Emplacement** : `src/Module/Core/Infrastructure/Doctrine/AuditListener.php` + +Listener Doctrine (pas EventSubscriber — deprecie Symfony 8) utilisant `#[AsDoctrineListener]`. + +**Evenements** : +- `onFlush` : collecte les changesets (aucune ecriture) +- `postFlush` : ecrit via `AuditLogWriter` (hors transaction Doctrine) + +**Dependances** : +- `AuditLogWriter` +- `LoggerInterface` + +**Logique `onFlush`** : +1. Recupere `UnitOfWork` +2. Parcourt insertions, updates, deletions +3. Pour chaque entite : verifie `#[Auditable]` via `ReflectionClass::getAttributes()` +4. Filtre les proprietes `#[AuditIgnore]` + blacklist hardcodee +5. Formate les changements : + - **create** : snapshot complet de toutes les proprietes non-ignorees + - **update** : `{champ: {old: x, new: y}}` via `getEntityChangeSet()` + - **delete** : snapshot complet +6. ManyToOne : log l'ID via `?->getId()` (null-safe pour les relations nullable), pas l'objet +7. Stocke dans `$pendingLogs` (propriete privee) + +**Logique `postFlush`** — pattern swap-and-clear (protection contre flush re-entrant) : +1. Copie `$pendingLogs` dans variable locale, vide immediatement `$this->pendingLogs = []` +2. Pour chaque log copie → `AuditLogWriter::log()` +3. Try/catch : erreur → `$logger->error(...)`, jamais de crash + +**Cas particuliers** : +- Flush sans changement → rien +- Entite sans `#[Auditable]` → ignoree +- Batch (fixtures, import) → chaque entite auditee, groupees par `request_id` +- Console → `performed_by = 'system'`, `ip_address = null`, `request_id = null` +- ManyToMany : non couvert par `getEntityChangeSet()` — limitation connue. Les changements de collections (ex: `User::$rbacRoles`) ne sont pas audites. Ajout futur possible via `getScheduledCollectionUpdates()`. + +--- + +## API Platform — Lecture seule + +### `AuditLogResource` + +**Emplacement** : `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php` + +**Operations** : +- `GET /api/audit-logs` — collection paginee (30 items/page), tri `performed_at DESC` +- `GET /api/audit-logs/{id}` — detail + +**Securite** : `is_granted('core.audit_log.view')` — permission RBAC, 403 sinon + +**Pas d'endpoints d'ecriture** : POST, PUT, PATCH, DELETE → 405 + +### `AuditLogOutput` + +**Emplacement** : `src/Module/Core/Application/DTO/AuditLogOutput.php` + +DTO readonly avec les champs : `id`, `entityType`, `entityId`, `action`, `changes`, `performedBy`, `performedAt`, `ipAddress`, `requestId`. + +### `AuditLogProvider` + +**Emplacement** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php` + +Provider DBAL (pas Doctrine ORM). + +**Filtres** (query params, combinables en AND) : +- `entity_type` : filtre exact +- `entity_id` : filtre exact +- `action` : filtre exact +- `performed_by` : filtre exact +- `performed_at[after]` : date minimum (incluse) +- `performed_at[before]` : date maximum (incluse) + +**Pagination** : via un `DbalPaginator` implementant `ApiPlatform\State\Pagination\PaginatorInterface` (`getCurrentPage()`, `getLastPage()`, `getTotalItems()`, `getItemsPerPage()`, `count()`, `getIterator()`). Le provider retourne ce paginator — API Platform genere automatiquement `hydra:view`. Pas de construction manuelle de la pagination. + +### Permission RBAC + +Ajouter dans `CoreModule::permissions()` : +- `core.audit_log.view` + +--- + +## Frontend + +### Composable `useAuditLog.ts` + +**Emplacement** : `frontend/shared/composables/useAuditLog.ts` + +Composable partage, reutilise par la page globale (ticket 4) et le composant timeline (ticket 5). + +**Methodes** : +- `fetchLogs(filters?: AuditLogFilters): Promise>` +- `fetchLogById(id: string): Promise` +- `fetchEntityLogs(entityType: string, entityId: string, page?: number): Promise>` + +Utilise `useApi().get()`. + +Si le composable maintient du state singleton (refs module-level pour cache), il doit exposer `resetAuditLog()` et etre reinitialise au logout (regle CLAUDE.md). + +### Types + +Ajouter dans `frontend/shared/types/index.ts` : + +```typescript +export interface AuditLogEntry { + id: string + entityType: string + entityId: string + action: 'create' | 'update' | 'delete' + changes: Record + performedBy: string + performedAt: string + ipAddress: string | null + requestId: string | null +} + +export interface AuditLogFilters { + entityType?: string + entityId?: string + action?: string + performedBy?: string + performedAtAfter?: string + performedAtBefore?: string + page?: number +} + +interface HydraView { + 'hydra:first'?: string + 'hydra:last'?: string + 'hydra:next'?: string + 'hydra:previous'?: string +} +``` + +Le type `HydraView` doit etre ajoute dans `frontend/shared/utils/api.ts` (a cote de `HydraCollection`) et `HydraCollection` doit etre etendu avec un champ optionnel `'hydra:view'?: HydraView`. + +### Page `admin/audit-log.vue` + +**Emplacement** : `frontend/modules/core/pages/admin/audit-log.vue` + +**Acces** : permission RBAC `core.audit_log.view` (verifie via `usePermissions().can('core.audit_log.view')`) + +**Elements** : +- Tableau pagine avec style projet (header `bg-tertiary-500`, rows hover) +- Filtres : plage dates, type entite (select), utilisateur (input), action (checkboxes), bouton reset +- Filtres persistes dans les query params URL +- Ligne expandable au clic : + - update : tableau champ / ancienne valeur / nouvelle valeur + - create/delete : snapshot complet +- Badges action : + - create : `bg-green-100 text-green-800` + - update : `bg-yellow-100 text-yellow-800` + - delete : `bg-red-100 text-red-800` +- Pagination prev/next via `hydra:view` +- Etat vide : message i18n "Aucune activite enregistree" +- Chargement initial : 30 dernieres entrees sans filtre + +### Sidebar + +Ajouter entree dans `config/sidebar.php` : +- Label : `sidebar.core.audit_log` +- Route : `/admin/audit-log` +- Icon : a definir (ex: `mdi:clipboard-text-clock`) +- Module : `core` +- Permission : `core.audit_log.view` — filtre automatiquement cote SidebarProvider + +### Composant `AuditTimeline.vue` + +**Emplacement** : `frontend/shared/components/audit/AuditTimeline.vue` + +Composant reutilisable, auto-importe par Nuxt. + +**Props** : +- `entityType: string` +- `entityId: string | number` + +**Comportement** : +- Garde permission : si `!usePermissions().can('core.audit_log.view')` → rendu vide, aucun appel API +- Timeline verticale : bordure gauche (`border-l-2 border-gray-200`) + dots colores par action +- Chaque entree : icone + date relative FR (`Intl.RelativeTimeFormat('fr')`) + date absolue en tooltip + utilisateur + resume +- Update : affiche old → new par champ +- Lazy loading : 10 items initiaux + bouton "Voir plus" +- Skeleton loader pendant le chargement +- Etat vide : "Aucun historique" + +**Premiere integration** : sur la page `admin/audit-log.vue` + +--- + +## i18n + +Cles a ajouter dans `frontend/i18n/locales/fr.json` : + +Structure imbriquee (respecte le format existant de `fr.json`) : + +```json +{ + "sidebar": { + "core": { + "audit_log": "Journal d'audit" + } + }, + "audit": { + "action": { + "create": "Création", + "update": "Modification", + "delete": "Suppression" + }, + "entity": { + "user": "Utilisateur" + }, + "empty": "Aucune activité enregistrée", + "no_results": "Aucun résultat pour ces filtres", + "timeline": { + "empty": "Aucun historique", + "load_more": "Voir plus" + }, + "filters": { + "reset": "Réinitialiser", + "date_from": "Du", + "date_to": "Au", + "entity_type": "Type d'entité", + "user": "Utilisateur", + "action": "Action" + }, + "detail": { + "field": "Champ", + "old_value": "Ancienne valeur", + "new_value": "Nouvelle valeur" + } + } +} +``` + +--- + +## Ordre d'implementation + +``` +Ticket 1 ────► Ticket 2 ────► Ticket 3 ────┬──► Ticket 4 + Table + Attributs + API │ Page admin + Writer Listener read-only │ + └──► Ticket 5 + Timeline + (4 et 5 en parallele) +``` + +--- + +## Decisions techniques (issues reviews) + +- **Connexion DBAL dediee** : `AuditLogWriter` utilise une connexion separee `audit` (meme DSN) pour eviter l'entanglement transactionnel avec l'ORM en batch +- **PaginatorInterface** : le provider retourne un `DbalPaginator` implementant l'interface API Platform — pas de construction manuelle `hydra:view` +- **Type natif `uuid` PG** : 16 octets vs 36 en varchar, index 40% plus petit sur table append-only a croissance infinie +- **Pattern swap-and-clear** dans `postFlush` : protection contre flush re-entrant +- **Blacklist exact-match** sur noms de proprietes (`password`, `plainPassword`, `token`, `secret`) — en defense-in-depth avec `#[AuditIgnore]` +- **ManyToMany non audite** : limitation connue, `getEntityChangeSet()` ne couvre pas les collections +- **Erreur audit silencieuse** : loguee, jamais propagee — pas de retry/dead-letter (acceptable pour CRM interne) +- **`entity_type` format `module.Entity`** : evite collisions si deux modules ont des entites de meme nom + +## Dependances externes + +- `symfony/uid` : generation UUID v7 (id) et v4 (request_id) diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 5170bfe..7527977 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -26,7 +26,8 @@ "core": { "roles": "Gestion des rôles", "users": "Utilisateurs", - "sites": "Sites" + "sites": "Sites", + "audit_log": "Journal d'audit" } }, "dashboard": { @@ -66,6 +67,35 @@ "switchSuccess": "Site courant changé" } }, + "audit": { + "action": { + "create": "Création", + "update": "Modification", + "delete": "Suppression" + }, + "entity": { + "user": "Utilisateur" + }, + "empty": "Aucune activité enregistrée", + "no_results": "Aucun résultat pour ces filtres", + "timeline": { + "empty": "Aucun historique", + "load_more": "Voir plus" + }, + "filters": { + "reset": "Réinitialiser", + "date_from": "Du", + "date_to": "Au", + "entity_type": "Type d'entité", + "user": "Utilisateur", + "action": "Action" + }, + "detail": { + "field": "Champ", + "old_value": "Ancienne valeur", + "new_value": "Nouvelle valeur" + } + }, "success": { "auth": { "logout": "Deconnexion reussie" @@ -132,6 +162,21 @@ "updated": "Permissions mises à jour avec succès" } }, + "auditLog": { + "title": "Journal d'audit", + "table": { + "performedAt": "Date", + "performedBy": "Utilisateur", + "entityType": "Entité", + "entityId": "ID", + "action": "Action", + "summary": "Résumé" + }, + "pagination": { + "previous": "Précédent", + "next": "Suivant" + } + }, "sites": { "title": "Gestion des sites", "newSite": "Nouveau site", diff --git a/frontend/modules/core/pages/admin/audit-log.vue b/frontend/modules/core/pages/admin/audit-log.vue new file mode 100644 index 0000000..f4b6494 --- /dev/null +++ b/frontend/modules/core/pages/admin/audit-log.vue @@ -0,0 +1,356 @@ + + + diff --git a/frontend/shared/components/audit/AuditLogDetail.vue b/frontend/shared/components/audit/AuditLogDetail.vue new file mode 100644 index 0000000..e444347 --- /dev/null +++ b/frontend/shared/components/audit/AuditLogDetail.vue @@ -0,0 +1,65 @@ + + + diff --git a/frontend/shared/components/audit/AuditTimeline.vue b/frontend/shared/components/audit/AuditTimeline.vue new file mode 100644 index 0000000..61212d3 --- /dev/null +++ b/frontend/shared/components/audit/AuditTimeline.vue @@ -0,0 +1,204 @@ + + + diff --git a/frontend/shared/composables/useAuditLog.ts b/frontend/shared/composables/useAuditLog.ts new file mode 100644 index 0000000..778cc51 --- /dev/null +++ b/frontend/shared/composables/useAuditLog.ts @@ -0,0 +1,89 @@ +import { ref } from 'vue' +import type { AuditLogEntry, AuditLogFilters } from '~/shared/types' +import type { HydraCollection } from '~/shared/utils/api' +import { onAuthSessionCleared } from '~/shared/stores/auth' + +/** + * Cache module-level : evite un double-fetch si la page et le composant + * Timeline demandent la meme page simultanement. Volontairement minimaliste : + * on ne cache que le dernier resultat, pas un LRU par filtre — un CRM interne + * n'en a pas besoin et le cache complexe complique le reset. + * + * Un logout / 401 doit purger ce cache : on s'enregistre au callback + * `onAuthSessionCleared` expose par auth.ts. + */ +const lastCollection = ref | null>(null) + +function resetAuditLog(): void { + lastCollection.value = null +} + +// Auto-enregistrement singleton : si la session est invalidee (401, +// logout) le cache est purge automatiquement, evitant qu'un autre user +// connecte ensuite ne voit des donnees residuelles. +onAuthSessionCleared(resetAuditLog) + +/** + * Traduit le modele front (camelCase) en query params API Platform + * (snake_case, avec la syntaxe performed_at[after] / [before]). + * + * @returns objet plat directement consommable par `useApi().get(url, query)`. + */ +function buildQuery(filters: AuditLogFilters | undefined): Record { + const query: Record = {} + if (!filters) return query + + if (filters.entityType) query.entity_type = filters.entityType + if (filters.entityId) query.entity_id = filters.entityId + if (filters.action) query.action = filters.action + if (filters.performedBy) query.performed_by = filters.performedBy + if (filters.performedAtAfter) query['performed_at[after]'] = filters.performedAtAfter + if (filters.performedAtBefore) query['performed_at[before]'] = filters.performedAtBefore + if (filters.page) query.page = filters.page + + return query +} + +/** + * Composable partage entre la page globale d'audit (admin) et le composant + * Timeline. Expose des methodes de lecture + une fonction `resetAuditLog()` + * pour purger le cache (conforme a la regle CLAUDE.md sur les composables + * singletons, cf. `useSidebar.resetSidebar`). + */ +export function useAuditLog() { + const api = useApi() + + async function fetchLogs(filters?: AuditLogFilters): Promise> { + const data = await api.get>( + '/audit-logs', + buildQuery(filters), + { toast: false }, + ) + lastCollection.value = data + return data + } + + async function fetchLogById(id: string): Promise { + return api.get(`/audit-logs/${id}`, {}, { toast: false }) + } + + async function fetchEntityLogs( + entityType: string, + entityId: string | number, + page: number = 1, + ): Promise> { + return fetchLogs({ + entityType, + entityId: String(entityId), + page, + }) + } + + return { + lastCollection, + fetchLogs, + fetchLogById, + fetchEntityLogs, + resetAuditLog, + } +} diff --git a/frontend/shared/types/index.ts b/frontend/shared/types/index.ts index 1e51be7..e7e9367 100644 --- a/frontend/shared/types/index.ts +++ b/frontend/shared/types/index.ts @@ -9,3 +9,37 @@ export interface SidebarSection { icon: string items: SidebarItem[] } + +/** + * Entree d'audit telle qu'elle est renvoyee par GET /api/audit-logs. + * + * `changes` est un payload libre dont le format depend de `action` : + * - `create` / `delete` : snapshot complet { champ: valeur } ; + * - `update` : diff { champ: { old, new } }. + */ +export interface AuditLogEntry { + id: string + entityType: string + entityId: string + action: 'create' | 'update' | 'delete' + changes: Record + performedBy: string + performedAt: string + ipAddress: string | null + requestId: string | null +} + +/** + * Filtres combinables en query params (AND) pour GET /api/audit-logs. + * Les bornes de date utilisent la syntaxe API Platform `performed_at[after]` / + * `performed_at[before]`. + */ +export interface AuditLogFilters { + entityType?: string + entityId?: string + action?: string + performedBy?: string + performedAtAfter?: string + performedAtBefore?: string + page?: number +} diff --git a/frontend/shared/utils/api.ts b/frontend/shared/utils/api.ts index 36affd1..fecbb8d 100644 --- a/frontend/shared/utils/api.ts +++ b/frontend/shared/utils/api.ts @@ -1,6 +1,16 @@ +export interface HydraView { + '@id'?: string + '@type'?: string + 'hydra:first'?: string + 'hydra:last'?: string + 'hydra:next'?: string + 'hydra:previous'?: string +} + export interface HydraCollection { 'hydra:member': T[] 'hydra:totalItems': number + 'hydra:view'?: HydraView } export function extractHydraMembers(collection: HydraCollection): T[] { diff --git a/migrations/Version20260420202749.php b/migrations/Version20260420202749.php new file mode 100644 index 0000000..abff7c2 --- /dev/null +++ b/migrations/Version20260420202749.php @@ -0,0 +1,63 @@ +addSql(<<<'SQL' + CREATE TABLE audit_log ( + id uuid NOT NULL, + entity_type VARCHAR(100) NOT NULL, + entity_id VARCHAR(64) NOT NULL, + action VARCHAR(10) NOT NULL, + changes JSONB NOT NULL DEFAULT '{}'::jsonb, + performed_by VARCHAR(100) NOT NULL, + performed_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, + ip_address VARCHAR(45) DEFAULT NULL, + request_id VARCHAR(36) DEFAULT NULL, + PRIMARY KEY(id) + ) + SQL); + + // Index pour recherche par entite (detail d'historique d'un objet). + $this->addSql('CREATE INDEX idx_audit_entity_time ON audit_log (entity_type, entity_id, performed_at)'); + + // Index pour recherche par utilisateur (qui a fait quoi). + $this->addSql('CREATE INDEX idx_audit_performer ON audit_log (performed_by, performed_at)'); + + // Index pour tri chronologique global (listing pagine DESC). + $this->addSql('CREATE INDEX idx_audit_time ON audit_log (performed_at)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE audit_log'); + } +} diff --git a/phpunit.dist.xml b/phpunit.dist.xml index eb794bd..a3c1c38 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -16,6 +16,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Module/Core/Application/DTO/AuditLogOutput.php b/src/Module/Core/Application/DTO/AuditLogOutput.php new file mode 100644 index 0000000..d84ad5a --- /dev/null +++ b/src/Module/Core/Application/DTO/AuditLogOutput.php @@ -0,0 +1,30 @@ + */ + public array $changes, + public string $performedBy, + public DateTimeImmutable $performedAt, + public ?string $ipAddress, + public ?string $requestId, + ) {} +} diff --git a/src/Module/Core/CoreModule.php b/src/Module/Core/CoreModule.php index 5b9b3d8..8b3be4f 100644 --- a/src/Module/Core/CoreModule.php +++ b/src/Module/Core/CoreModule.php @@ -34,6 +34,7 @@ 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.audit_log.view', 'label' => 'Consulter le journal d\'audit'], ]; } } diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index fc5106f..5f5a4a9 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -21,6 +21,8 @@ use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository; // (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; +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; @@ -63,6 +65,7 @@ use Symfony\Component\Serializer\Attribute\SerializedName; )] #[ORM\Entity(repositoryClass: DoctrineUserRepository::class)] #[ORM\Table(name: '`user`')] +#[Auditable] class User implements UserInterface, PasswordAuthenticatedUserInterface { #[ORM\Id] @@ -155,9 +158,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface private ?SiteInterface $currentSite = null; #[ORM\Column] + #[AuditIgnore] private ?string $password = null; #[Groups(['user:write'])] + #[AuditIgnore] private ?string $plainPassword = null; #[ORM\Column(type: 'datetime_immutable')] diff --git a/src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php b/src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php new file mode 100644 index 0000000..adda23c --- /dev/null +++ b/src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php @@ -0,0 +1,74 @@ + + */ +final readonly class DbalPaginator implements PaginatorInterface, IteratorAggregate +{ + /** + * @param list $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 + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->items); + } +} diff --git a/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php b/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php new file mode 100644 index 0000000..94ee273 --- /dev/null +++ b/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php @@ -0,0 +1,53 @@ + 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, + security: "is_granted('core.audit_log.view')", + provider: AuditLogProvider::class, + ), + new Get( + uriTemplate: '/audit-logs/{id}', + security: "is_granted('core.audit_log.view')", + provider: AuditLogProvider::class, + ), + ], + output: AuditLogOutput::class, +)] +final class AuditLogResource {} diff --git a/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php b/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php new file mode 100644 index 0000000..7a0848a --- /dev/null +++ b/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php @@ -0,0 +1,177 @@ +provideItem((string) $uriVariables['id']); + } + + return $this->provideCollection($operation, $context); + } + + private function provideItem(string $id): ?AuditLogOutput + { + /** @var array|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 $context + */ + private function provideCollection(Operation $operation, array $context): DbalPaginator + { + $page = $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') + ->setFirstResult($offset) + ->setMaxResults($itemsPerPage) + ; + + $countQuery = $this->buildBaseQuery()->select('COUNT(*)'); + + $this->applyFilters($dataQuery, $filters); + $this->applyFilters($countQuery, $filters); + + /** @var list> $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 $raw + * + * @return array{entity_type?: string, entity_id?: string, action?: string, performed_by?: string, performed_at_after?: string, performed_at_before?: string} + */ + private function extractFilters(array $raw): array + { + $filters = []; + + foreach (['entity_type', 'entity_id', 'action', 'performed_by'] as $key) { + if (isset($raw[$key]) && is_string($raw[$key]) && '' !== $raw[$key]) { + $filters[$key] = $raw[$key]; + } + } + + // Filtres de plage `performed_at[after]` / `performed_at[before]`. + if (isset($raw['performed_at']) && is_array($raw['performed_at'])) { + $range = $raw['performed_at']; + if (isset($range['after']) && is_string($range['after']) && '' !== $range['after']) { + $filters['performed_at_after'] = $range['after']; + } + if (isset($range['before']) && is_string($range['before']) && '' !== $range['before']) { + $filters['performed_at_before'] = $range['before']; + } + } + + return $filters; + } + + /** + * @param array $filters + */ + private function applyFilters(QueryBuilder $qb, array $filters): void + { + if (isset($filters['entity_type'])) { + $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'])) { + $qb->andWhere('performed_by = :performed_by')->setParameter('performed_by', $filters['performed_by']); + } + 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 $row + */ + private function hydrate(array $row): AuditLogOutput + { + /** @var string $rawChanges */ + $rawChanges = $row['changes'] ?? '{}'; + + /** @var array $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, + ); + } +} diff --git a/src/Module/Core/Infrastructure/Audit/AuditLogWriter.php b/src/Module/Core/Infrastructure/Audit/AuditLogWriter.php new file mode 100644 index 0000000..d467abb --- /dev/null +++ b/src/Module/Core/Infrastructure/Audit/AuditLogWriter.php @@ -0,0 +1,97 @@ + 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 $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 : JSON encode jsonb + datetimetz. + '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]`. + * + * @param array $data + * + * @return array + */ + private function stripSensitive(array $data): array + { + foreach (self::SENSITIVE_KEYS as $sensitiveKey) { + unset($data[$sensitiveKey]); + } + + return $data; + } +} diff --git a/src/Module/Core/Infrastructure/Audit/RequestIdProvider.php b/src/Module/Core/Infrastructure/Audit/RequestIdProvider.php new file mode 100644 index 0000000..b194f5a --- /dev/null +++ b/src/Module/Core/Infrastructure/Audit/RequestIdProvider.php @@ -0,0 +1,42 @@ +isMainRequest()) { + return; + } + + $this->requestId = Uuid::v4()->toRfc4122(); + } + + public function getRequestId(): ?string + { + return $this->requestId; + } +} diff --git a/src/Module/Core/Infrastructure/Doctrine/AuditListener.php b/src/Module/Core/Infrastructure/Doctrine/AuditListener.php new file mode 100644 index 0000000..aac32c6 --- /dev/null +++ b/src/Module/Core/Infrastructure/Doctrine/AuditListener.php @@ -0,0 +1,336 @@ +getId()`). + */ +#[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(); + + 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'); + } + } + + 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 + { + $class = $entity::class; + + if (!$this->isAuditable($class)) { + return; + } + + $metadata = $em->getClassMetadata($class); + + $changes = match ($action) { + 'update' => $this->buildUpdateChanges($entity, $uow, $class), + 'create', 'delete' => $this->buildSnapshot($entity, $metadata, $class), + 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, + ]; + } + + /** + * 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. + * + * @return array + */ + private function buildSnapshot(object $entity, ClassMetadata $metadata, string $class): 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; + } + + $mapping = $metadata->getAssociationMapping($assoc); + // On ne snapshot que les references scalaires (to-one) ; les + // collections to-many sont volumineuses et souvent non utiles + // a figer dans un audit (cf. limitation ManyToMany). + if (!$metadata->isSingleValuedAssociation($assoc)) { + continue; + } + + $related = $metadata->getFieldValue($entity, $assoc); + $snapshot[$assoc] = null !== $related && method_exists($related, 'getId') + ? $related->getId() + : null; + } + + 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/src/Shared/Domain/Attribute/AuditIgnore.php b/src/Shared/Domain/Attribute/AuditIgnore.php new file mode 100644 index 0000000..18f046f --- /dev/null +++ b/src/Shared/Domain/Attribute/AuditIgnore.php @@ -0,0 +1,19 @@ + 405). + * + * Seed : on insere 3 lignes temoins directement via DBAL (pas via l'ORM) + * pour eviter la recursion du listener. Les lignes sont supprimees en + * tearDown par le request_id tag specifique au run. + * + * @internal + */ +final class AuditLogApiTest extends AbstractApiTestCase +{ + private Connection $auditConnection; + + private string $runTag; + + protected function setUp(): void + { + parent::setUp(); + self::bootKernel(); + + /** @var Connection $conn */ + $conn = self::getContainer()->get('doctrine.dbal.audit_connection'); + $this->auditConnection = $conn; + + $this->runTag = 'api_audit_'.bin2hex(random_bytes(4)); + $this->seedAuditLog(); + } + + protected function tearDown(): void + { + $this->auditConnection->executeStatement( + 'DELETE FROM audit_log WHERE request_id = :tag', + ['tag' => $this->runTag], + ); + parent::tearDown(); + } + + public function testUnauthenticatedRequestGets401(): void + { + $client = self::createClient(); + $response = $client->request('GET', '/api/audit-logs'); + + self::assertSame(401, $response->getStatusCode()); + } + + public function testAuthenticatedUserWithoutPermissionGets403(): void + { + // Utilise `core.users.view` comme permission non-liee (l'user n'a pas audit_log.view). + $credentials = $this->createUserWithPermission('core.users.view'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $response = $client->request('GET', '/api/audit-logs'); + + self::assertSame(403, $response->getStatusCode()); + } + + public function testAuthenticatedUserWithPermissionGets200(): void + { + $credentials = $this->createUserWithPermission('core.audit_log.view'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $response = $client->request('GET', '/api/audit-logs'); + + self::assertSame(200, $response->getStatusCode()); + + $data = $response->toArray(); + self::assertArrayHasKey('member', $data); + self::assertArrayHasKey('totalItems', $data); + } + + public function testAdminGets200(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/audit-logs'); + + self::assertSame(200, $response->getStatusCode()); + } + + public function testFilterByEntityType(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/audit-logs?entity_type=core.User&action=update'); + + self::assertSame(200, $response->getStatusCode()); + $data = $response->toArray(); + $members = $data['member']; + + // On verifie qu'il n'y a que des lignes matching nos filtres dans les resultats de notre run + // (d'autres lignes antérieures au run peuvent exister, mais le filtre doit etre respecte). + foreach ($members as $member) { + self::assertSame('core.User', $member['entityType']); + self::assertSame('update', $member['action']); + } + } + + public function testOrderedByPerformedAtDesc(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + // On cible les 3 lignes seedees via le filtre `entity_id=999` (unique a ce test). + $response = $client->request('GET', '/api/audit-logs?'.http_build_query(['entity_type' => 'core.User', 'entity_id' => '999'])); + + self::assertSame(200, $response->getStatusCode()); + + $data = $response->toArray(); + $members = array_values(array_filter( + $data['member'], + fn (array $m) => ($m['requestId'] ?? null) === $this->runTag, + )); + + self::assertCount(3, $members, 'Les 3 lignes seedees doivent etre visibles'); + // Tri DESC : le plus recent d'abord. + $timestamps = array_map(fn (array $m) => strtotime((string) $m['performedAt']), $members); + $sortedDesc = $timestamps; + rsort($sortedDesc); + self::assertSame($sortedDesc, $timestamps, 'Les lignes doivent etre triees par performedAt DESC'); + } + + public function testItemEndpointReturns200WithPermission(): void + { + $row = $this->auditConnection->fetchAssociative( + 'SELECT id FROM audit_log WHERE request_id = :tag LIMIT 1', + ['tag' => $this->runTag], + ); + self::assertIsArray($row); + + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/audit-logs/'.$row['id']); + + self::assertSame(200, $response->getStatusCode()); + $data = $response->toArray(); + self::assertSame($row['id'], $data['id']); + } + + public function testPostIsNotAllowed(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('POST', '/api/audit-logs', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['entityType' => 'core.User', 'entityId' => '1', 'action' => 'create', 'changes' => []], + ]); + + self::assertContains($response->getStatusCode(), [404, 405], 'POST doit etre refuse (pas d\'operation d\'ecriture exposee)'); + } + + /** + * Insere 3 lignes temoins taggees avec le runTag pour un nettoyage sur. + */ + private function seedAuditLog(): void + { + $now = new DateTimeImmutable('now', new DateTimeZone('UTC')); + + $fixtures = [ + [ + 'entity_type' => 'core.User', + 'entity_id' => '999', + 'action' => 'update', + 'changes' => ['isAdmin' => ['old' => false, 'new' => true]], + 'performed_by' => 'admin', + 'performed_at' => $now->modify('-2 hours'), + ], + [ + 'entity_type' => 'core.User', + 'entity_id' => '999', + 'action' => 'update', + 'changes' => ['username' => ['old' => 'x', 'new' => 'y']], + 'performed_by' => 'admin', + 'performed_at' => $now->modify('-1 hour'), + ], + [ + 'entity_type' => 'core.User', + 'entity_id' => '999', + 'action' => 'delete', + 'changes' => ['username' => 'y'], + 'performed_by' => 'admin', + 'performed_at' => $now, + ], + ]; + + foreach ($fixtures as $row) { + $this->auditConnection->insert('audit_log', [ + 'id' => Uuid::v7()->toRfc4122(), + 'entity_type' => $row['entity_type'], + 'entity_id' => $row['entity_id'], + 'action' => $row['action'], + 'changes' => json_encode($row['changes'], JSON_THROW_ON_ERROR), + 'performed_by' => $row['performed_by'], + 'performed_at' => $row['performed_at']->format('Y-m-d H:i:sO'), + 'ip_address' => null, + 'request_id' => $this->runTag, + ]); + } + } +} diff --git a/tests/Module/Core/Infrastructure/Audit/AuditLogWriterTest.php b/tests/Module/Core/Infrastructure/Audit/AuditLogWriterTest.php new file mode 100644 index 0000000..9932540 --- /dev/null +++ b/tests/Module/Core/Infrastructure/Audit/AuditLogWriterTest.php @@ -0,0 +1,163 @@ +, 2: array} + * + * Capture de l'appel `insert()` : [$table, $data, $types] + */ + private ?array $capturedInsert = null; + + private Connection $connection; + + private RequestStack $requestStack; + + private RequestIdProvider $requestIdProvider; + + protected function setUp(): void + { + $this->capturedInsert = null; + + $this->connection = $this->createMock(Connection::class); + $this->connection + ->method('insert') + ->willReturnCallback(function (string $table, array $data, array $types = []): int { + $this->capturedInsert = [$table, $data, $types]; + + return 1; + }) + ; + + $this->requestStack = new RequestStack(); + $this->requestIdProvider = new RequestIdProvider(); + } + + public function testLogsCreateWithAuthenticatedUser(): void + { + $security = $this->buildSecurityWithUser('alice'); + $writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider); + + $writer->log('core.User', '42', 'create', ['username' => 'alice']); + + $this->assertNotNull($this->capturedInsert); + [$table, $data] = $this->capturedInsert; + $this->assertSame('audit_log', $table); + $this->assertSame('core.User', $data['entity_type']); + $this->assertSame('42', $data['entity_id']); + $this->assertSame('create', $data['action']); + $this->assertSame(['username' => 'alice'], $data['changes']); + $this->assertSame('alice', $data['performed_by']); + } + + public function testUsesSystemWhenNoAuthenticatedUser(): void + { + $security = $this->buildSecurityWithUser(null); + $writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider); + + $writer->log('core.User', '1', 'update', ['isAdmin' => ['old' => false, 'new' => true]]); + + $this->assertSame('system', $this->capturedInsert[1]['performed_by']); + } + + public function testStripsSensitiveKeys(): void + { + $security = $this->buildSecurityWithUser('alice'); + $writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider); + + $writer->log('core.User', '1', 'create', [ + 'username' => 'bob', + 'password' => 'topsecrethash', + 'plainPassword' => 'clear', + 'token' => 'abc', + 'secret' => 'xyz', + 'email' => 'bob@example.com', + ]); + + $changes = $this->capturedInsert[1]['changes']; + $this->assertArrayNotHasKey('password', $changes); + $this->assertArrayNotHasKey('plainPassword', $changes); + $this->assertArrayNotHasKey('token', $changes); + $this->assertArrayNotHasKey('secret', $changes); + $this->assertSame('bob', $changes['username']); + $this->assertSame('bob@example.com', $changes['email']); + } + + public function testCapturesIpAddressWhenRequestPresent(): void + { + $request = Request::create('/api/users', 'POST'); + $request->server->set('REMOTE_ADDR', '203.0.113.42'); + $this->requestStack->push($request); + + $security = $this->buildSecurityWithUser('alice'); + $writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider); + + $writer->log('core.User', '1', 'create', []); + + $this->assertSame('203.0.113.42', $this->capturedInsert[1]['ip_address']); + } + + public function testIpAddressNullInCli(): void + { + $security = $this->buildSecurityWithUser(null); + $writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider); + + $writer->log('core.User', '1', 'create', []); + + $this->assertNull($this->capturedInsert[1]['ip_address']); + $this->assertNull($this->capturedInsert[1]['request_id']); + } + + public function testGeneratesUuidV7PrimaryKey(): void + { + $security = $this->buildSecurityWithUser('alice'); + $writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider); + + $writer->log('core.User', '1', 'create', []); + + $id = $this->capturedInsert[1]['id']; + // UUID v7 : le 13e caractere (apres les tirets) vaut "7". + // Format : xxxxxxxx-xxxx-7xxx-xxxx-xxxxxxxxxxxx + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i', + $id + ); + } + + private function buildSecurityWithUser(?string $username): Security + { + $security = $this->createMock(Security::class); + $user = null !== $username ? new InMemoryUser($username, 'pwd') : null; + $security->method('getUser')->willReturn($user); + + return $security; + } +} diff --git a/tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php b/tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php new file mode 100644 index 0000000..523500b --- /dev/null +++ b/tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php @@ -0,0 +1,176 @@ + IDs de users crees par le test (nettoyage en tearDown) */ + private array $createdUserIds = []; + + private string $testRunTag; + + protected function setUp(): void + { + self::bootKernel(); + + /** @var EntityManagerInterface $em */ + $em = self::getContainer()->get('doctrine')->getManager(); + $this->em = $em; + + /** @var Connection $conn */ + $conn = self::getContainer()->get('doctrine.dbal.audit_connection'); + $this->auditConnection = $conn; + + // Tag unique par run pour filtrer les lignes audit_log produites + // exclusivement par ce test (la table n'a ni truncate ni rollback). + $this->testRunTag = 'audit_test_'.bin2hex(random_bytes(4)); + } + + protected function tearDown(): void + { + // Suppression explicite des users crees (cascade sur user_role / + // user_site via les ORM mappings) + nettoyage des lignes audit + // correspondantes pour ne pas polluer les runs suivants. + if ([] !== $this->createdUserIds) { + foreach ($this->createdUserIds as $id) { + $user = $this->em->find(User::class, $id); + if (null !== $user) { + $this->em->remove($user); + } + } + $this->em->flush(); + } + + $this->auditConnection->executeStatement( + "DELETE FROM audit_log WHERE entity_type = 'core.User' AND changes->>'username' LIKE :tag", + ['tag' => $this->testRunTag.'%'], + ); + + parent::tearDown(); + } + + public function testLogsCreateOnUserInsertion(): void + { + $user = $this->makeUser(); + + $this->em->persist($user); + $this->em->flush(); + $this->createdUserIds[] = $user->getId(); + + $rows = $this->fetchAuditRows($user->getId()); + + $this->assertCount(1, $rows, 'Une ligne audit attendue a la creation'); + $row = $rows[0]; + $this->assertSame('core.User', $row['entity_type']); + $this->assertSame('create', $row['action']); + $this->assertSame((string) $user->getId(), $row['entity_id']); + + $changes = json_decode($row['changes'], true, 512, JSON_THROW_ON_ERROR); + $this->assertArrayHasKey('username', $changes); + $this->assertArrayNotHasKey('password', $changes, 'password doit etre #[AuditIgnore]'); + $this->assertArrayNotHasKey('plainPassword', $changes, 'plainPassword doit etre #[AuditIgnore]'); + } + + public function testLogsUpdateWithDiff(): void + { + $user = $this->makeUser(); + $this->em->persist($user); + $this->em->flush(); + $this->createdUserIds[] = $user->getId(); + + // Reset de la baseline : on ne garde que la ligne update. + $this->auditConnection->executeStatement( + 'DELETE FROM audit_log WHERE entity_id = :id AND entity_type = \'core.User\'', + ['id' => (string) $user->getId()], + ); + + $user->setIsAdmin(true); + $this->em->flush(); + + $rows = $this->fetchAuditRows($user->getId()); + $this->assertCount(1, $rows); + $this->assertSame('update', $rows[0]['action']); + + $changes = json_decode($rows[0]['changes'], true, 512, JSON_THROW_ON_ERROR); + $this->assertArrayHasKey('isAdmin', $changes); + $this->assertSame(false, $changes['isAdmin']['old']); + $this->assertSame(true, $changes['isAdmin']['new']); + } + + public function testLogsDeleteSnapshot(): void + { + $user = $this->makeUser(); + $this->em->persist($user); + $this->em->flush(); + $userId = $user->getId(); + + $this->em->remove($user); + $this->em->flush(); + + $rows = $this->fetchAuditRows($userId); + // Deux lignes : la creation + la suppression. + $actions = array_column($rows, 'action'); + $this->assertContains('delete', $actions); + + $deleteRow = $rows[array_search('delete', $actions, true)]; + $changes = json_decode($deleteRow['changes'], true, 512, JSON_THROW_ON_ERROR); + $this->assertArrayHasKey('username', $changes); + $this->assertArrayNotHasKey('password', $changes); + + // On nettoie a la main les lignes restantes (user deja delete). + $this->auditConnection->executeStatement( + 'DELETE FROM audit_log WHERE entity_id = :id AND entity_type = \'core.User\'', + ['id' => (string) $userId], + ); + } + + /** + * @return list + */ + private function fetchAuditRows(int $userId): array + { + /** @var list $rows */ + return $this->auditConnection->fetchAllAssociative( + 'SELECT id, entity_type, entity_id, action, changes FROM audit_log WHERE entity_type = :type AND entity_id = :id ORDER BY performed_at ASC', + ['type' => 'core.User', 'id' => (string) $userId], + ); + } + + private function makeUser(): User + { + /** @var UserPasswordHasherInterface $hasher */ + $hasher = self::getContainer()->get(UserPasswordHasherInterface::class); + + $user = new User(); + $user->setUsername($this->testRunTag.'_'.bin2hex(random_bytes(2))); + $user->setIsAdmin(false); + $user->setPassword($hasher->hashPassword($user, 'testpass')); + + return $user; + } +} -- 2.39.5 From 37eafd276c60bc47228850cca43f6c0342ef27f2 Mon Sep 17 00:00:00 2001 From: matthieu Date: Mon, 20 Apr 2026 21:10:46 +0200 Subject: [PATCH 02/37] fix(audit-log) : address code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker - Frontend attendait `hydra:member` / `hydra:totalItems` / `hydra:view` mais API Platform 4 sert `member` / `totalItems` / `view` (sans prefixe) sous ld+json, et un tableau plat sous json. Consequence : tableau admin et timeline silencieusement vides. Fix : `useAuditLog` force `Accept: application/ld+json` (necessaire pour obtenir l'objet Hydra avec pagination), types `HydraCollection`/`HydraView` renommes, composants accedent aux proprietes sans prefixe. Nouveau test fonctionnel verrouille le format. Should-fix - `AuditLogWriter` : ajout de `'id' => Types::GUID` pour expliciter le type natif PG `uuid` (fonctionnait par cast implicite mais l'intention etait floue). - `AuditListener` docblock : documente que le DQL bulk DELETE/UPDATE et `Connection::executeStatement()` bypassent le listener (onFlush non appele). Piege pour les futures commandes de purge. - `AuditLogResource` : ajout d'une regex UUID dans `requirements` de l'operation Get — un `GET /api/audit-logs/not-a-uuid` produisait un 500 (cast PG rejete) au lieu d'un 404. - `audit-log.vue` : le watcher des filtres faisait `filters.page = 1` ce qui declenchait le watcher de `page`, causant deux `loadEntries()` en parallele. Fusionne : la navigation page appelle `loadEntries()` directement depuis `goPrevious`/`goNext`, plus de watcher dedie. - `useAuditLog.fetchEntityLogs` : bypass du cache `lastCollection` pour ne pas polluer la reference page-level quand la timeline est ouverte. - `AuditTimeline.vue` : remplacement du `
` vide par un `v-if` sur le wrapper — aucun DOM quand l'utilisateur n'a pas le droit. - `AuditListenerTest` tag : retire le `_` (wildcard LIKE SQL) du prefix pour eviter un faux negatif de match cross-test. - `AuditLogApiTest` : proprietes `auditConnection` / `runTag` nullable et tearDown guarde, sinon un echec setUp provoquait un fatal typed-property au lieu de propager l'exception d'origine. Stabilite suite de tests - `doctrine.yaml when@test` : `idle_connection_ttl: 1` sur les deux connexions pour eviter l'accumulation de connexions orphelines. - tearDown des tests audit : `close()` explicite sur la connexion audit apres chaque test. - `docker-compose.yml` : `max_connections=300` sur la DB dev (defaut PG=100 insuffisant pour 220+ tests * 2 connexions/test). --- config/packages/doctrine.yaml | 8 +++ docker-compose.yml | 5 +- .../modules/core/pages/admin/audit-log.vue | 21 ++++--- .../shared/components/audit/AuditTimeline.vue | 15 +++-- frontend/shared/composables/useAuditLog.ts | 28 +++++++-- frontend/shared/utils/api.ts | 31 +++++++--- .../ApiPlatform/Resource/AuditLogResource.php | 1 + .../Infrastructure/Audit/AuditLogWriter.php | 5 +- .../Infrastructure/Doctrine/AuditListener.php | 6 ++ tests/Module/Core/Api/AuditLogApiTest.php | 61 ++++++++++++++++--- .../Doctrine/AuditListenerTest.php | 9 ++- 11 files changed, 153 insertions(+), 37 deletions(-) diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 2864ce8..fddef1e 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -44,11 +44,19 @@ when@test: # la connexion `audit` ecrirait dans la base dev pendant que l'ORM # ecrit dans la base test — divergence invisible en apparence mais # fatale pour les tests du journal d'audit. + # + # `idle_connection_ttl: 1` (au lieu du defaut 600s) : en test on + # reboote le kernel a chaque test. Sans TTL court, les connexions + # orphelines s'accumulent dans PG et on finit par saturer le pool + # (max_connections=100) sur une suite de 200+ tests qui utilisent + # 2 connexions chacun (default + audit). connections: default: dbname_suffix: '_test%env(default::TEST_TOKEN)%' + idle_connection_ttl: 1 audit: dbname_suffix: '_test%env(default::TEST_TOKEN)%' + idle_connection_ttl: 1 orm: mappings: # Entite fictive SiteAware utilisee uniquement en tests du diff --git a/docker-compose.yml b/docker-compose.yml index 9815dd2..451caf0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,7 +45,10 @@ services: restart: unless-stopped db: image: postgres:16-alpine - command: -p ${POSTGRES_PORT:-5436} + # max_connections eleve (defaut PG=100) pour absorber la suite de tests : + # ~220 tests * kernel reboot par test * 2 connexions (default + audit) + # peut saturer le pool, meme avec idle_connection_ttl court cote Doctrine. + command: -p ${POSTGRES_PORT:-5436} -c max_connections=300 environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} diff --git a/frontend/modules/core/pages/admin/audit-log.vue b/frontend/modules/core/pages/admin/audit-log.vue index f4b6494..bbc6afe 100644 --- a/frontend/modules/core/pages/admin/audit-log.vue +++ b/frontend/modules/core/pages/admin/audit-log.vue @@ -159,7 +159,7 @@ - +
+ +
+ {{ field }} : + −{{ diff.removed.join(', ') }} + + +{{ diff.added.join(', ') }} +
{{ snapshotSummary(entry) }} @@ -104,29 +111,41 @@ const page = ref(1) const totalItems = ref(0) const loading = ref(false) -// Lazy loading : 10 items max par page visible cote UX. Le back fixe la -// limite a 30 (paginationItemsPerPage de AuditLogResource) ; on coupe a 10 -// dans le composant pour ne pas saturer le flux visuel, et on laisse -// l'utilisateur demander plus via "Voir plus". -const INITIAL_LIMIT = 10 +// Lazy loading : 10 items par page cote UX. On aligne la pagination backend +// (itemsPerPage=10 dans fetchEntityLogs) avec cette taille pour eviter de +// slicer cote client — sinon les items 11-30 de chaque page etaient ignores. +const PAGE_SIZE = 10 + +// Anti-race : un utilisateur qui change rapidement d'entite affichee (ouvre +// une ligne puis une autre dans le tableau admin) peut declencher deux fetchs +// dont le premier repond en retard et ecrase l'etat de la seconde timeline. +// On incremente un token a chaque fetch ; seule la derniere requete ecrit le +// resultat. loadMore() est aussi protege : une reponse tardive append sur +// une timeline dont l'entite a deja change serait visuellement confuse. +let requestToken = 0 const hasMore = computed(() => entries.value.length < totalItems.value) async function loadPage(targetPage: number, append: boolean): Promise { if (!canView.value) return + const token = ++requestToken loading.value = true try { - const data = await fetchEntityLogs(entityType.value, entityId.value, targetPage) - const slice = (data.member ?? []).slice(0, append ? undefined : INITIAL_LIMIT) - entries.value = append ? [...entries.value, ...slice] : slice + const data = await fetchEntityLogs(entityType.value, entityId.value, targetPage, PAGE_SIZE) + if (token !== requestToken) return + const items = data.member ?? [] + entries.value = append ? [...entries.value, ...items] : items totalItems.value = data.totalItems ?? entries.value.length page.value = targetPage } catch { + if (token !== requestToken) return // Erreur silencieuse (timeline secondaire) — useApi n'affiche pas de toast avec toast: false. entries.value = append ? entries.value : [] } finally { - loading.value = false + if (token === requestToken) { + loading.value = false + } } } @@ -179,6 +198,22 @@ function updateDiff(entry: AuditLogEntry): Record { + // Format to-many : { champ: { added: [ids], removed: [ids] } } produit + // par AuditListener::captureCollectionChange. + const out: Record = {} + for (const [key, value] of Object.entries(entry.changes)) { + if (value && typeof value === 'object' && 'added' in value && 'removed' in value) { + const diff = value as { added: unknown; removed: unknown } + out[key] = { + added: Array.isArray(diff.added) ? diff.added : [], + removed: Array.isArray(diff.removed) ? diff.removed : [], + } + } + } + return out +} + function snapshotSummary(entry: AuditLogEntry): string { const keys = Object.keys(entry.changes) if (keys.length === 0) return '—' diff --git a/frontend/shared/composables/useAuditLog.ts b/frontend/shared/composables/useAuditLog.ts index ba695e7..faac0d7 100644 --- a/frontend/shared/composables/useAuditLog.ts +++ b/frontend/shared/composables/useAuditLog.ts @@ -1,5 +1,5 @@ import { ref } from 'vue' -import type { AuditLogEntry, AuditLogFilters } from '~/shared/types' +import type { AuditLogEntityTypes, AuditLogEntry, AuditLogFilters } from '~/shared/types' import type { HydraCollection } from '~/shared/utils/api' import { onAuthSessionCleared } from '~/shared/stores/auth' @@ -29,17 +29,25 @@ onAuthSessionCleared(resetAuditLog) * * @returns objet plat directement consommable par `useApi().get(url, query)`. */ -function buildQuery(filters: AuditLogFilters | undefined): Record { - const query: Record = {} +function buildQuery(filters: AuditLogFilters | undefined): Record { + const query: Record = {} if (!filters) return query - if (filters.entityType) query.entity_type = filters.entityType + // `entity_type` : chaine simple ou liste pour un filtre multi-selection. + // Cote PHP, la syntaxe `entity_type[]=X&entity_type[]=Y` est requise pour + // que $_GET['entity_type'] soit un tableau (sinon "last wins"). + if (Array.isArray(filters.entityType)) { + if (filters.entityType.length > 0) query['entity_type[]'] = filters.entityType + } else if (filters.entityType) { + query.entity_type = filters.entityType + } if (filters.entityId) query.entity_id = filters.entityId if (filters.action) query.action = filters.action if (filters.performedBy) query.performed_by = filters.performedBy if (filters.performedAtAfter) query['performed_at[after]'] = filters.performedAtAfter if (filters.performedAtBefore) query['performed_at[before]'] = filters.performedAtBefore if (filters.page) query.page = filters.page + if (filters.itemsPerPage) query.itemsPerPage = filters.itemsPerPage return query } @@ -84,18 +92,39 @@ export function useAuditLog() { return api.get(`/audit-logs/${id}`, {}, { toast: false, headers: JSONLD_HEADERS }) } + /** + * Liste des valeurs distinctes de `entity_type` pour alimenter le filtre + * multi-selection. Alimente par un endpoint DBAL, aucune cache cote front + * (la liste peut evoluer a chaque nouvelle ecriture d'audit). + */ + async function fetchEntityTypes(): Promise { + const data = await api.get( + '/audit-log-entity-types', + {}, + { toast: false, headers: JSONLD_HEADERS }, + ) + return data.entityTypes ?? [] + } + async function fetchEntityLogs( entityType: string, entityId: string | number, page: number = 1, + itemsPerPage: number = 10, ): Promise> { // Volontairement via `fetchLogs` (sans cache) pour ne pas ecraser // `lastCollection` — la timeline peut etre rendue simultanement a // la page globale et doit rester independante. + // + // Le backend pagine a 30 par defaut (paginationItemsPerPage) ; on + // passe explicitement itemsPerPage ici pour que la taille de page + // soit alignee avec l'UX timeline (10 items + bouton "Voir plus"). + // Sans ce param, le client slice a 10 et rate 20 entrees par page. return fetchLogs({ entityType, entityId: String(entityId), page, + itemsPerPage, }) } @@ -104,6 +133,7 @@ export function useAuditLog() { fetchLogs: fetchLogsCached, fetchLogById, fetchEntityLogs, + fetchEntityTypes, resetAuditLog, } } diff --git a/frontend/shared/types/index.ts b/frontend/shared/types/index.ts index e7e9367..9658c9d 100644 --- a/frontend/shared/types/index.ts +++ b/frontend/shared/types/index.ts @@ -35,11 +35,18 @@ export interface AuditLogEntry { * `performed_at[before]`. */ export interface AuditLogFilters { - entityType?: string + /** Chaine pour un seul type, liste pour un filtre multi-selection. */ + entityType?: string | string[] entityId?: string action?: string performedBy?: string performedAtAfter?: string performedAtBefore?: string page?: number + itemsPerPage?: number +} + +export interface AuditLogEntityTypes { + id: string + entityTypes: string[] } diff --git a/src/Module/Core/Domain/Entity/Permission.php b/src/Module/Core/Domain/Entity/Permission.php index e780ff3..9825a05 100644 --- a/src/Module/Core/Domain/Entity/Permission.php +++ b/src/Module/Core/Domain/Entity/Permission.php @@ -11,6 +11,7 @@ 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; @@ -31,6 +32,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'])] diff --git a/src/Module/Core/Domain/Entity/Role.php b/src/Module/Core/Domain/Entity/Role.php index 1f84615..a6b4bcf 100644 --- a/src/Module/Core/Domain/Entity/Role.php +++ b/src/Module/Core/Domain/Entity/Role.php @@ -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.')] diff --git a/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php b/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php new file mode 100644 index 0000000..5892f6c --- /dev/null +++ b/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php @@ -0,0 +1,34 @@ + $entityTypes */ + public function __construct( + public readonly string $id = 'entity-types', + public readonly array $entityTypes = [], + ) {} +} diff --git a/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php b/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php index 387fb08..1f428b9 100644 --- a/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php +++ b/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php @@ -39,6 +39,8 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogProvider; new GetCollection( uriTemplate: '/audit-logs', paginationItemsPerPage: 30, + paginationClientItemsPerPage: true, + paginationMaximumItemsPerPage: 100, security: "is_granted('core.audit_log.view')", provider: AuditLogProvider::class, ), diff --git a/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php b/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php new file mode 100644 index 0000000..eee33e5 --- /dev/null +++ b/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php @@ -0,0 +1,35 @@ + + */ +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 $types */ + $types = $this->connection + ->executeQuery('SELECT DISTINCT entity_type FROM audit_log ORDER BY entity_type ASC') + ->fetchFirstColumn() + ; + + return new AuditLogEntityTypesResource(entityTypes: $types); + } +} diff --git a/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php b/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php index 7a0848a..f7d6137 100644 --- a/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php +++ b/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php @@ -11,6 +11,7 @@ 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; @@ -100,13 +101,30 @@ final readonly class AuditLogProvider implements ProviderInterface /** * @param array $raw * - * @return array{entity_type?: string, entity_id?: string, action?: string, performed_by?: string, performed_at_after?: string, performed_at_before?: string} + * @return array{entity_type?: list|string, entity_id?: string, action?: string, performed_by?: string, performed_at_after?: string, performed_at_before?: string} */ private function extractFilters(array $raw): array { $filters = []; - foreach (['entity_type', 'entity_id', 'action', 'performed_by'] as $key) { + // `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 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', 'action', 'performed_by'] as $key) { if (isset($raw[$key]) && is_string($raw[$key]) && '' !== $raw[$key]) { $filters[$key] = $raw[$key]; } @@ -127,12 +145,18 @@ final readonly class AuditLogProvider implements ProviderInterface } /** - * @param array $filters + * @param array|string> $filters */ private function applyFilters(QueryBuilder $qb, array $filters): void { if (isset($filters['entity_type'])) { - $qb->andWhere('entity_type = :entity_type')->setParameter('entity_type', $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']); @@ -141,7 +165,15 @@ final readonly class AuditLogProvider implements ProviderInterface $qb->andWhere('action = :action')->setParameter('action', $filters['action']); } if (isset($filters['performed_by'])) { - $qb->andWhere('performed_by = :performed_by')->setParameter('performed_by', $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). La clause `ESCAPE '\\'` indique + // a PostgreSQL le caractere d'echappement utilise dans le motif. + $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $filters['performed_by']); + $qb->andWhere("performed_by ILIKE :performed_by ESCAPE '\\'") + ->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']); diff --git a/src/Module/Core/Infrastructure/Doctrine/AuditListener.php b/src/Module/Core/Infrastructure/Doctrine/AuditListener.php index 3011e49..98b302f 100644 --- a/src/Module/Core/Infrastructure/Doctrine/AuditListener.php +++ b/src/Module/Core/Infrastructure/Doctrine/AuditListener.php @@ -14,6 +14,7 @@ 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; @@ -42,10 +43,17 @@ use Throwable; * 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 changements de collections ManyToMany ne sont pas tracees - * (`getEntityChangeSet()` ne les couvre pas). Extension future via - * `getScheduledCollectionUpdates()`. * - 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 @@ -105,6 +113,18 @@ final class AuditListener 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 @@ -152,18 +172,29 @@ final class AuditListener private function capturePendingLog(object $entity, EntityManagerInterface $em, UnitOfWork $uow, string $action): void { - $class = $entity::class; + // 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; } - $metadata = $em->getClassMetadata($class); - + // 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', 'delete' => $this->buildSnapshot($entity, $metadata, $class), - default => [], + 'create' => $this->buildSnapshot($entity, $metadata, $class, includeCollections: false), + 'delete' => $this->buildSnapshot($entity, $metadata, $class, includeCollections: true), + default => [], }; if ('update' === $action && [] === $changes) { @@ -187,6 +218,115 @@ final class AuditListener ]; } + /** + * 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, @@ -218,9 +358,18 @@ final class AuditListener * 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): array + private function buildSnapshot(object $entity, ClassMetadata $metadata, string $class, bool $includeCollections): array { $ignored = $this->getIgnoredProperties($class); $snapshot = []; @@ -238,18 +387,32 @@ final class AuditListener continue; } - $mapping = $metadata->getAssociationMapping($assoc); - // On ne snapshot que les references scalaires (to-one) ; les - // collections to-many sont volumineuses et souvent non utiles - // a figer dans un audit (cf. limitation ManyToMany). - if (!$metadata->isSingleValuedAssociation($assoc)) { + if ($metadata->isSingleValuedAssociation($assoc)) { + $related = $metadata->getFieldValue($entity, $assoc); + $snapshot[$assoc] = null !== $related && method_exists($related, 'getId') + ? $related->getId() + : null; + continue; } - $related = $metadata->getFieldValue($entity, $assoc); - $snapshot[$assoc] = null !== $related && method_exists($related, 'getId') - ? $related->getId() - : null; + 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; diff --git a/src/Module/Sites/Domain/Entity/Site.php b/src/Module/Sites/Domain/Entity/Site.php index 71a2f3a..79f384b 100644 --- a/src/Module/Sites/Domain/Entity/Site.php +++ b/src/Module/Sites/Domain/Entity/Site.php @@ -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.')] diff --git a/src/Module/Sites/Infrastructure/Doctrine/DoctrineSiteRepository.php b/src/Module/Sites/Infrastructure/Doctrine/DoctrineSiteRepository.php index 13ea032..756a2d7 100644 --- a/src/Module/Sites/Infrastructure/Doctrine/DoctrineSiteRepository.php +++ b/src/Module/Sites/Infrastructure/Doctrine/DoctrineSiteRepository.php @@ -34,7 +34,7 @@ class DoctrineSiteRepository extends ServiceEntityRepository implements SiteRepo */ public function findAllOrderedByName(): array { - /** @var list $sites */ + // @var list $sites return $this->findBy([], ['name' => 'ASC']); } diff --git a/tests/Module/Core/Api/AuditLogApiTest.php b/tests/Module/Core/Api/AuditLogApiTest.php index 609ea8b..32e66b5 100644 --- a/tests/Module/Core/Api/AuditLogApiTest.php +++ b/tests/Module/Core/Api/AuditLogApiTest.php @@ -95,13 +95,6 @@ final class AuditLogApiTest extends AbstractApiTestCase self::assertArrayHasKey('totalItems', $data); } - /** - * Le frontend force `Accept: application/ld+json` dans `useAuditLog` pour - * recuperer les cles prefixees `hydra:*` (et `hydra:view` pour la - * pagination). Ce test verrouille ce contrat : sans lui, un changement - * de configuration API Platform cassant le JSON-LD passerait inaperçu - * et le tableau admin apparaitrait silencieusement vide en production. - */ /** * Le frontend demande explicitement `application/ld+json` dans `useAuditLog` * pour obtenir l'objet Hydra complet (`member`, `totalItems`, `view`). Sous @@ -206,6 +199,139 @@ final class AuditLogApiTest extends AbstractApiTestCase self::assertContains($response->getStatusCode(), [404, 405], 'POST doit etre refuse (pas d\'operation d\'ecriture exposee)'); } + /** + * Filtre multi-valeurs `entity_type[]=X&entity_type[]=Y` : l'union des + * deux types est retournee. On seed 2 types differents (core.User et + * core.Role) et on verifie que les deux apparaissent sous notre runTag, + * et qu'une valeur non existante (`core.Nonexistent`) n'ajoute rien. + * + * On interroge avec itemsPerPage=100 pour englober nos 5 lignes quel + * que soit le bruit de lignes preexistantes dans audit_log. + */ + public function testFilterByMultipleEntityTypes(): void + { + // Seed 2 lignes supplementaires avec un autre entity_type. + $this->seedExtraRow('core.Role', '1001', 'create'); + $this->seedExtraRow('core.Role', '1002', 'update'); + + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/audit-logs?'.http_build_query([ + 'entity_type' => ['core.User', 'core.Role', 'core.Nonexistent'], + 'itemsPerPage' => 100, + ])); + + self::assertSame(200, $response->getStatusCode()); + $data = $response->toArray(); + + // Filtre sur notre runTag pour isoler nos 5 lignes (3 User + 2 Role) + // independamment des entrees pre-existantes de la table. + $ours = array_values(array_filter( + $data['member'], + fn (array $m) => ($m['requestId'] ?? null) === $this->runTag, + )); + self::assertCount(5, $ours, 'Les 3 lignes core.User + 2 lignes core.Role doivent etre retournees.'); + + $types = array_unique(array_map(fn (array $m) => $m['entityType'], $ours)); + sort($types); + self::assertSame(['core.Role', 'core.User'], $types); + + // Verifier qu'aucune ligne hors filtre n'apparait dans la reponse. + foreach ($data['member'] as $member) { + self::assertContains($member['entityType'], ['core.User', 'core.Role']); + } + } + + /** + * Recherche partielle insensible a la casse sur `performed_by` via ILIKE. + * Le seed utilise `performed_by=admin` ; on cherche `ADM` pour tester + * a la fois la casse et le wildcard contains. + */ + public function testFilterByPerformedByPartialMatch(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/audit-logs?performed_by=ADM&entity_id=999'); + + self::assertSame(200, $response->getStatusCode()); + $data = $response->toArray(); + $ours = array_filter($data['member'], fn (array $m) => ($m['requestId'] ?? null) === $this->runTag); + self::assertGreaterThan(0, count($ours), 'La recherche ILIKE doit matcher "ADM" -> "admin".'); + } + + /** + * Les caracteres wildcard PostgreSQL (`%`, `_`) saisis par l'utilisateur + * doivent etre echappes et traites comme caracteres litteraux, pas comme + * des metacaracteres LIKE. Idem pour le backslash qui doit etre double + * pour ne pas interferer avec la clause ESCAPE. + */ + public function testFilterByPerformedByEscapesWildcards(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + + // `%` seul doit matcher 0 ligne (personne n'a `%` dans performed_by). + $response = $client->request('GET', '/api/audit-logs?performed_by=%25&entity_id=999'); + self::assertSame(200, $response->getStatusCode()); + $data = $response->toArray(); + $ours = array_filter($data['member'], fn (array $m) => ($m['requestId'] ?? null) === $this->runTag); + self::assertCount(0, $ours, '% doit etre traite comme literal, pas wildcard.'); + + // `_` seul (wildcard single-char en LIKE) doit aussi matcher 0 ligne. + $response = $client->request('GET', '/api/audit-logs?performed_by=_&entity_id=999'); + self::assertSame(200, $response->getStatusCode()); + $data = $response->toArray(); + $ours = array_filter($data['member'], fn (array $m) => ($m['requestId'] ?? null) === $this->runTag); + self::assertCount(0, $ours, '_ doit etre traite comme literal, pas wildcard single-char.'); + + // `\` (backslash) dans le motif ne doit pas casser la clause ESCAPE : + // on attend une reponse 200 (pas 500), meme si le resultat est vide. + $response = $client->request('GET', '/api/audit-logs?performed_by=%5C&entity_id=999'); + self::assertSame(200, $response->getStatusCode(), 'Un backslash dans le filtre ne doit pas produire de 500.'); + } + + /** + * L'endpoint `/api/audit-log-entity-types` retourne la liste des valeurs + * distinctes de `entity_type` presentes dans la table. La presence du + * seed runTag garantit au moins `core.User`. + */ + public function testEntityTypesEndpointReturnsDistinctTypes(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/audit-log-entity-types'); + + self::assertSame(200, $response->getStatusCode()); + $data = $response->toArray(); + self::assertArrayHasKey('entityTypes', $data); + self::assertIsArray($data['entityTypes']); + self::assertContains('core.User', $data['entityTypes']); + } + + public function testEntityTypesEndpointRequiresPermission(): void + { + $credentials = $this->createUserWithPermission('core.users.view'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $response = $client->request('GET', '/api/audit-log-entity-types'); + + self::assertSame(403, $response->getStatusCode()); + } + + /** + * Helper interne pour seeder une ligne additionnelle avec un entity_type + * arbitraire, taggee runTag pour nettoyage en tearDown. + */ + private function seedExtraRow(string $entityType, string $entityId, string $action): void + { + $this->auditConnection->insert('audit_log', [ + 'id' => Uuid::v7()->toRfc4122(), + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'action' => $action, + 'changes' => json_encode(['field' => ['old' => 1, 'new' => 2]], JSON_THROW_ON_ERROR), + 'performed_by' => 'admin', + 'performed_at' => new DateTimeImmutable('now', new DateTimeZone('UTC'))->format('Y-m-d H:i:sO'), + 'ip_address' => null, + 'request_id' => $this->runTag, + ]); + } + /** * Insere 3 lignes temoins taggees avec le runTag pour un nettoyage sur. */ @@ -220,7 +346,10 @@ final class AuditLogApiTest extends AbstractApiTestCase 'action' => 'update', 'changes' => ['isAdmin' => ['old' => false, 'new' => true]], 'performed_by' => 'admin', - 'performed_at' => $now->modify('-2 hours'), + // Offsets faibles (secondes) : garantit que les 3 lignes + // restent parmi les plus recentes de audit_log meme quand la + // table contient plusieurs centaines de lignes historiques. + 'performed_at' => $now->modify('-2 seconds'), ], [ 'entity_type' => 'core.User', @@ -228,7 +357,7 @@ final class AuditLogApiTest extends AbstractApiTestCase 'action' => 'update', 'changes' => ['username' => ['old' => 'x', 'new' => 'y']], 'performed_by' => 'admin', - 'performed_at' => $now->modify('-1 hour'), + 'performed_at' => $now->modify('-1 second'), ], [ 'entity_type' => 'core.User', diff --git a/tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php b/tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php index 24b6841..8f16cce 100644 --- a/tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php +++ b/tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Tests\Module\Core\Infrastructure\Doctrine; +use App\Module\Core\Domain\Entity\Permission; +use App\Module\Core\Domain\Entity\Role; use App\Module\Core\Domain\Entity\User; use Doctrine\DBAL\Connection; use Doctrine\ORM\EntityManagerInterface; @@ -32,6 +34,9 @@ final class AuditListenerTest extends KernelTestCase /** @var list IDs de users crees par le test (nettoyage en tearDown) */ private array $createdUserIds = []; + /** @var list IDs de roles crees par le test (nettoyage en tearDown) */ + private array $createdRoleIds = []; + private string $testRunTag; protected function setUp(): void @@ -66,6 +71,24 @@ final class AuditListenerTest extends KernelTestCase $this->em->flush(); } + if ([] !== $this->createdRoleIds) { + foreach ($this->createdRoleIds as $id) { + $role = $this->em->find(Role::class, $id); + if (null !== $role) { + $this->em->remove($role); + } + } + $this->em->flush(); + // Nettoie egalement les lignes audit de ces roles (entity_id est + // une colonne text, on delete en boucle pour simplifier le binding). + foreach ($this->createdRoleIds as $id) { + $this->auditConnection->executeStatement( + 'DELETE FROM audit_log WHERE entity_type = \'core.Role\' AND entity_id = :id', + ['id' => (string) $id], + ); + } + } + $this->auditConnection->executeStatement( "DELETE FROM audit_log WHERE entity_type = 'core.User' AND changes->>'username' LIKE :tag", ['tag' => $this->testRunTag.'%'], @@ -154,6 +177,157 @@ final class AuditListenerTest extends KernelTestCase ); } + /** + * Regression test : une entite recuperee via `getReference()` (proxy / + * ghost object lazy) doit etre auditee avec le FQCN canonique. Sur + * Doctrine ORM 3 + PHP 8.4, les lazy ghosts preservent `::class` reel + * — mais sous Doctrine 2 ou en cas de retour a un `__CG__\` proxy, + * l'audit doit toujours resoudre la classe via `ClassMetadata` et + * jamais aboutir a un `entity_type` de type `Proxies\__CG__\...\User`. + */ + public function testLogsUpdateOnProxyEntity(): void + { + $user = $this->makeUser(); + $this->em->persist($user); + $this->em->flush(); + $userId = (int) $user->getId(); + $this->createdUserIds[] = $userId; + + // Detache puis recupere via getReference : sur Doctrine 2, renvoie + // un `Proxies\__CG__\...\User` ; sur Doctrine 3 + PHP 8.4 le ghost + // object reste instance de la classe reelle — dans tous les cas la + // resolution via ClassMetadata doit produire un audit correct. + $this->em->clear(); + + $proxy = $this->em->getReference(User::class, $userId); + self::assertNotNull($proxy); + + // Reset de la baseline : on ne garde que la ligne update du proxy. + $this->auditConnection->executeStatement( + 'DELETE FROM audit_log WHERE entity_id = :id AND entity_type = \'core.User\'', + ['id' => (string) $userId], + ); + + $proxy->setIsAdmin(true); + $this->em->flush(); + + $rows = $this->fetchAuditRows($userId); + self::assertCount(1, $rows, 'La mutation sur un proxy doit etre auditee.'); + self::assertSame('update', $rows[0]['action']); + // L'entity_type doit etre le FQCN canonique, pas celui du proxy. + self::assertSame('core.User', $rows[0]['entity_type']); + } + + /** + * Verifie que l'ajout d'une permission a un role est bien audite sous + * la forme `{permissions: {added: [id], removed: []}}`. Regression test + * pour le bug "ManyToMany collections ignorees par getEntityChangeSet". + */ + public function testLogsManyToManyCollectionAddition(): void + { + $roleCode = 'audittest_'.bin2hex(random_bytes(3)); + $role = new Role($roleCode, 'Test role '.$roleCode); + $this->em->persist($role); + $this->em->flush(); + $roleId = (int) $role->getId(); + $this->createdRoleIds[] = $roleId; + + // Reset baseline : on ne veut que le log de l'update de collection. + $this->auditConnection->executeStatement( + 'DELETE FROM audit_log WHERE entity_type = \'core.Role\' AND entity_id = :id', + ['id' => (string) $roleId], + ); + + // Recupere une permission existante (fixtures garantissent core.users.view). + $permission = $this->em->getRepository(Permission::class)->findOneBy(['code' => 'core.users.view']); + self::assertNotNull($permission, 'Fixture core.users.view manquante.'); + + $role->addPermission($permission); + $this->em->flush(); + + $rows = $this->fetchRoleAuditRows($roleId); + self::assertCount(1, $rows, 'Une ligne update attendue pour l\'ajout de permission.'); + self::assertSame('update', $rows[0]['action']); + + $changes = json_decode($rows[0]['changes'], true, 512, JSON_THROW_ON_ERROR); + self::assertArrayHasKey('permissions', $changes, 'Le changeset doit contenir le champ "permissions".'); + self::assertSame([], $changes['permissions']['removed']); + self::assertSame([(int) $permission->getId()], $changes['permissions']['added']); + } + + /** + * Symetrique : retirer une permission d'un role est audite sous + * `{permissions: {added: [], removed: [id]}}`. + */ + public function testLogsManyToManyCollectionRemoval(): void + { + $permission = $this->em->getRepository(Permission::class)->findOneBy(['code' => 'core.users.view']); + self::assertNotNull($permission); + + $roleCode = 'audittest_'.bin2hex(random_bytes(3)); + $role = new Role($roleCode, 'Test role '.$roleCode); + $role->addPermission($permission); + $this->em->persist($role); + $this->em->flush(); + $roleId = (int) $role->getId(); + $this->createdRoleIds[] = $roleId; + + // Reset baseline. + $this->auditConnection->executeStatement( + 'DELETE FROM audit_log WHERE entity_type = \'core.Role\' AND entity_id = :id', + ['id' => (string) $roleId], + ); + + $role->removePermission($permission); + $this->em->flush(); + + $rows = $this->fetchRoleAuditRows($roleId); + self::assertCount(1, $rows); + $changes = json_decode($rows[0]['changes'], true, 512, JSON_THROW_ON_ERROR); + self::assertSame([], $changes['permissions']['added']); + self::assertSame([(int) $permission->getId()], $changes['permissions']['removed']); + } + + /** + * Regression test : supprimer un role avec des permissions attachees doit + * preserver la liste des permissions dans le snapshot delete. C'etait le + * trou principal du fix ManyToMany initial (reviewer Codex round 2). + */ + public function testDeleteSnapshotIncludesManyToManyIds(): void + { + $permission = $this->em->getRepository(Permission::class)->findOneBy(['code' => 'core.users.view']); + self::assertNotNull($permission); + + $roleCode = 'audittest_'.bin2hex(random_bytes(3)); + $role = new Role($roleCode, 'Delete test '.$roleCode); + $role->addPermission($permission); + $this->em->persist($role); + $this->em->flush(); + $roleId = (int) $role->getId(); + + $this->em->remove($role); + $this->em->flush(); + + $rows = $this->fetchRoleAuditRows($roleId); + // create + update (permission ajoutee) + delete attendus. + $actions = array_column($rows, 'action'); + self::assertContains('delete', $actions); + + $deleteRow = $rows[array_search('delete', $actions, true)]; + $changes = json_decode($deleteRow['changes'], true, 512, JSON_THROW_ON_ERROR); + + // Le snapshot delete doit contenir la liste des IDs de permissions + // attachees au role au moment de la suppression. + self::assertArrayHasKey('permissions', $changes); + self::assertSame([(int) $permission->getId()], $changes['permissions']); + + // Nettoyage manuel (le role est deja supprime, on ne peut plus passer par $this->em). + $this->auditConnection->executeStatement( + 'DELETE FROM audit_log WHERE entity_type = \'core.Role\' AND entity_id = :id', + ['id' => (string) $roleId], + ); + } + /** * @return list */ @@ -178,4 +352,16 @@ final class AuditListenerTest extends KernelTestCase return $user; } + + /** + * @return list + */ + private function fetchRoleAuditRows(int $roleId): array + { + // @var list $rows + return $this->auditConnection->fetchAllAssociative( + 'SELECT id, entity_type, entity_id, action, changes FROM audit_log WHERE entity_type = :type AND entity_id = :id ORDER BY performed_at ASC', + ['type' => 'core.Role', 'id' => (string) $roleId], + ); + } } -- 2.39.5 From 6db955f65c5224744129d113a5130cc62e0bc909 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 22 Apr 2026 11:09:37 +0200 Subject: [PATCH 05/37] fix(api-docs) : reactive swagger ui en ajoutant symfony/twig-bundle API Platform 4 active swagger_ui/re_doc/scalar uniquement si TwigBundle est present (les UI de docs sont rendues via Twig). Sans lui les flags tombaient a false et /api/docs renvoyait 404 "Swagger UI, ReDoc and Scalar are disabled." sur Accept: text/html. --- composer.json | 1 + composer.lock | 274 +++++++++++++++++++++++++++++++++++++- config/bundles.php | 2 + config/packages/twig.yaml | 6 + templates/base.html.twig | 23 ++++ 5 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 config/packages/twig.yaml create mode 100644 templates/base.html.twig diff --git a/composer.json b/composer.json index ee7d9a9..eb9a778 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ "symfony/runtime": "8.0.*", "symfony/security-bundle": "8.0.*", "symfony/serializer": "8.0.*", + "symfony/twig-bundle": "8.0.*", "symfony/uid": "8.0.*", "symfony/validator": "8.0.*", "symfony/yaml": "8.0.*" diff --git a/composer.lock b/composer.lock index 6914aca..d6dc421 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "65f8419b8830b250fe461933fe240a14", + "content-hash": "d65a546151abb6b977fbf7f1c86d14fe", "packages": [ { "name": "api-platform/doctrine-common", @@ -7226,6 +7226,198 @@ ], "time": "2025-07-15T13:41:35+00:00" }, + { + "name": "symfony/twig-bridge", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bridge.git", + "reference": "a892d0b7f3d5d51b35895467e48aafbd1f2612a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/a892d0b7f3d5d51b35895467e48aafbd1f2612a0", + "reference": "a892d0b7f3d5d51b35895467e48aafbd1f2612a0", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/translation-contracts": "^2.5|^3", + "twig/twig": "^3.21" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/form": "<7.4.4|>8.0,<8.0.4", + "symfony/mime": "<7.4.8|>8.0,<8.0.8" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/asset": "^7.4|^8.0", + "symfony/asset-mapper": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/emoji": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/form": "^7.4.4|^8.0.4", + "symfony/html-sanitizer": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/mime": "^7.4.8|^8.0.8", + "symfony/polyfill-intl-icu": "^1.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/security-acl": "^2.8|^3.0", + "symfony/security-core": "^7.4|^8.0", + "symfony/security-csrf": "^7.4|^8.0", + "symfony/security-http": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/web-link": "^7.4|^8.0", + "symfony/workflow": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0", + "twig/cssinliner-extra": "^3", + "twig/inky-extra": "^3", + "twig/markdown-extra": "^3" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Twig\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Twig with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/twig-bridge/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/twig-bundle", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bundle.git", + "reference": "f83767b78e2580ca9fe9a2cf6fcff19cd5389bc1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/f83767b78e2580ca9fe9a2cf6fcff19cd5389bc1", + "reference": "f83767b78e2580ca9fe9a2cf6fcff19cd5389bc1", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "php": ">=8.4", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0" + }, + "require-dev": { + "symfony/asset": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/web-link": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\TwigBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration of Twig into the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/twig-bundle/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, { "name": "symfony/type-info", "version": "v8.0.8", @@ -7807,6 +7999,86 @@ ], "time": "2026-03-30T15:14:47+00:00" }, + { + "name": "twig/twig", + "version": "v3.24.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "a6769aefb305efef849dc25c9fd1653358c148f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/a6769aefb305efef849dc25c9fd1653358c148f0", + "reference": "a6769aefb305efef849dc25c9fd1653358c148f0", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" + }, + "require-dev": { + "php-cs-fixer/shim": "^3.0@stable", + "phpstan/phpstan": "^2.0@stable", + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Resources/core.php", + "src/Resources/debug.php", + "src/Resources/escaper.php", + "src/Resources/string_loader.php" + ], + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.24.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-03-17T21:31:11+00:00" + }, { "name": "webmozart/assert", "version": "2.1.6", diff --git a/config/bundles.php b/config/bundles.php index a7c2c43..6754b65 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -11,6 +11,7 @@ use Nelmio\CorsBundle\NelmioCorsBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\MonologBundle\MonologBundle; use Symfony\Bundle\SecurityBundle\SecurityBundle; +use Symfony\Bundle\TwigBundle\TwigBundle; return [ FrameworkBundle::class => ['all' => true], @@ -22,4 +23,5 @@ return [ DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], LexikJWTAuthenticationBundle::class => ['all' => true], MonologBundle::class => ['all' => true], + TwigBundle::class => ['all' => true], ]; diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml new file mode 100644 index 0000000..3f795d9 --- /dev/null +++ b/config/packages/twig.yaml @@ -0,0 +1,6 @@ +twig: + file_name_pattern: '*.twig' + +when@test: + twig: + strict_variables: true diff --git a/templates/base.html.twig b/templates/base.html.twig new file mode 100644 index 0000000..c6fd7ad --- /dev/null +++ b/templates/base.html.twig @@ -0,0 +1,23 @@ + + + + + {% block title %}Welcome!{% endblock %} + + {% block stylesheets %} + {% endblock %} + + {% block javascripts %} + {% endblock %} + + {% set frankenphpHotReload = app.request.server.get('FRANKENPHP_HOT_RELOAD') %} + {% if frankenphpHotReload %} + + + + {% endif %} + + + {% block body %}{% endblock %} + + -- 2.39.5 From 617ee314b35de074a97fdeb3632c67ac5cd0afef Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 22 Apr 2026 11:17:40 +0200 Subject: [PATCH 06/37] fix(users) : corrige l'affichage et l'ecrasement des sites sur le drawer RBAC Le drawer RBAC de /admin/users initialisait l'etat des sites a partir du payload /api/users (groupe user:list) qui n'expose pas la collection sites. Consequence : la section "Sites autorises" affichait toujours 0 case cochee, et la sauvegarde ecrasait silencieusement les sites existants en BDD. - Ajout d'une operation GET /users/{id}/rbac (groupe user:rbac:read) dediee au chargement du detail pour l'edition : payload list reste leger, detail riche sur une URI symetrique au PATCH existant. - Drawer charge desormais GET /users/{id}/rbac pour initialiser sites, roles et directPermissions ; UserListItem ne contient plus sites (inutilise). - Colonne "Sites" retiree de la table /admin/users : l'info est consultee via le drawer, pas la liste (evite aussi la fuite cross-site pour les users avec core.users.view mais sans sites.bypass_scope). - Garde anti-ecrasement dans UserRbacProcessor : respect de la semantique merge-patch+json (cle absente = preservee, cle = [] = vidage explicite). Restaure les collections ManyToMany absentes du payload a partir du snapshot Doctrine. Couvre roles, directPermissions et sites. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../core/components/UserRbacDrawer.vue | 49 +++++---- frontend/modules/core/pages/admin/users.vue | 27 +---- frontend/shared/types/rbac.ts | 14 ++- src/Module/Core/Domain/Entity/User.php | 10 ++ .../State/Processor/UserRbacProcessor.php | 103 ++++++++++++++++++ .../State/Processor/UserRbacProcessorTest.php | 10 ++ 6 files changed, 168 insertions(+), 45 deletions(-) diff --git a/frontend/modules/core/components/UserRbacDrawer.vue b/frontend/modules/core/components/UserRbacDrawer.vue index 2d6be9c..f4d61df 100644 --- a/frontend/modules/core/components/UserRbacDrawer.vue +++ b/frontend/modules/core/components/UserRbacDrawer.vue @@ -112,7 +112,7 @@ + + +``` + +Et ajouter les cles manquantes dans `fr.json` : + +```json +"audit": { + "entity": { + "core_user": "Utilisateur", + "core_role": "Rôle", + "core_permission": "Permission", + "sites_site": "Site" + } +} +``` + +--- + +### 4.4 MINEUR — `loadSidebar()` recharge inutile a chaque switch de site + +**Fichier** : `frontend/modules/sites/composables/useCurrentSite.ts:94-97` + +```typescript +await loadSidebar() // apres chaque switch +``` + +Commentaire : *"les filtres de modules peuvent dependre du site courant"*. En pratique, dans `config/sidebar.php` de Coltura aucun item ne depend du site. C'est un aller-retour reseau inutile a chaque switch, et la sidebar peut "flicker" pour l'utilisateur. + +**Correction** : rendre le rechargement opt-in ou documenter la raison actuelle (prevoir le futur). + +```typescript +// La sidebar ne depend actuellement d'aucun site, mais le /api/sidebar +// pourrait devenir site-scoped dans le futur (ex: items RH par site). +// On garde le reload pour etre defensif — cout : 1 RTT par switch (~100ms). +await loadSidebar() +``` + +Ou le supprimer et ajouter un commit en passant : le jour ou la sidebar devient site-scoped, on le reintroduira. + +--- + +### 4.5 MINEUR — Alias de retrocompat `SiteNotAuthorizedException` sans planning de suppression + +**Fichier** : `src/Module/Sites/Domain/Exception/SiteNotAuthorizedException.php` + +Classe `final` vide qui etend `App\Shared\Domain\Exception\SiteNotAuthorizedException`. Aucun usage restant dans la branche — c'est une dette technique a supprimer. + +**Correction** : rechercher les usages (`grep -r 'Sites\\Domain\\Exception\\SiteNotAuthorizedException'`), les remplacer, puis supprimer le fichier. + +--- + +## 5. Documentation et configuration + +### 5.1 MINEUR — `CHANGELOG.md` non mis a jour + +**Fichier** : `CHANGELOG.md` + +Toujours bloque sur `## [0.0.0]` avec un contenu pre-PR. Aucun resume de la feature audit-log, du module Sites, du systeme RBAC. + +**Correction** : ajouter des entrees `## [0.1.34]` (ou la version courante au merge) avec les sections `Added`, `Changed`, `Fixed`. + +--- + +### 5.2 MINEUR — `AuditLogEntityTypesResource` a un `id` hardcode inutile + +**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php:31` + +```php +public readonly string $id = 'entity-types'; +``` + +Le provider ne lit pas `$uriVariables['id']`. Ce champ est du bruit dans le DTO. Si quelqu'un regarde la reponse JSON en pensant "tiens, quel est cet id ?", il perd du temps. + +**Correction** : supprimer la propriete `$id`. + +--- + +### 5.3 MINEUR — Commentaire incorrect sur l'escape LIKE en PostgreSQL + +**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:175-176` + +Voir 1.5. Le commentaire affirme une propriete fausse de PostgreSQL. A corriger avec la fix du filtre. + +--- + +## 6. Frontend et UX + +### 6.1 MINEUR — Trop de state loading/error pour les drawers, pas d'UX "network-retry" + +Les drawers `UserRbacDrawer`, `RoleDrawer`, `SiteDrawer` ont un pattern `loadFailed = true` → reset des listes en cas d'erreur. Bon point pour eviter les donnees stale. Mais aucun bouton "Reessayer" n'est offert : l'utilisateur doit fermer et rouvrir le drawer pour relancer le fetch. Un bouton `MalioButton` "Reessayer" dans l'etat erreur ameliorerait l'UX. + +Non bloquant, juste une suggestion pour la prochaine iteration. + +--- + +### 6.2 MINEUR — `onMounted` dans `logout.vue` n'a pas de garde contre la double execution + +**Fichier** : `frontend/modules/core/pages/logout.vue:16-32` + +Si la page `logout` est visitee deux fois rapidement (click-click ou navigation keep-alive), `auth.logout()` est appele deux fois en parallele. Le backend Lexik JWT logout est idempotent donc c'est inoffensif, mais on peut voir deux toasts d'erreur si le reseau tombe pile entre les deux. + +Pas critique. A signaler pour info. + +--- + +## 7. Bonnes pratiques a retenir + +### Ce qui est vraiment bien fait dans cette PR + +1. **Pattern swap-and-clear dans `AuditListener::postFlush`** — La copie locale de `$pendingLogs` puis le vidage immediat avant l'iteration proteje contre les flushs re-entrants. Le try/catch par entree garantit qu'une erreur d'audit ne casse jamais le flow metier. C'est exactement ce que la spec demandait, implemente correctement. + +2. **Connexion DBAL dediee `audit` avec propagation du suffixe `_test`** — Piege classique rate dans beaucoup de projets : la connexion secondaire ecrit dans la base dev pendant que l'ORM ecrit dans la base test. Ici, `doctrine.yaml` propage `dbname_suffix` aux deux connexions en environnement test + `idle_connection_ttl: 1` pour ne pas saturer le pool. Propre. + +3. **Trois miroirs RBAC parfaitement synchronises** — `config/sidebar.php` + `frontend/tests/e2e/_fixtures/personas.ts` + `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php`. Les 6 personas et les 4 liens admin (`users`, `roles`, `sites`, `audit-log`) matchent a la ligne pres. C'est la regle la plus dure a tenir sur la duree. + +4. **Protection `AdminHeadcountGuard` avec limitation TOCTOU documentee honnetement** — Le commentaire du guard cite explicitement le risque accepte plutot que de le cacher. Pour un CRM interne mono-operateur, c'est la bonne decision d'architecture. + +5. **`useAuditLog` s'auto-enregistre via `onAuthSessionCleared`** — Respecte la regle "composable singleton = reset au logout". Idem pour `useSidebar`, `useModules`, `useCurrentSite`. Discipline appliquee partout. + +6. **Pagination cappee sur `AuditLogResource`** (`paginationMaximumItemsPerPage: 50`) — Bon reflexe defensif contre les requetes abusives sur le volume appele a croitre. + +7. **Tie-breaker sur `id` (UUID v7) en plus de `performed_at DESC`** — Garantit un tri deterministe meme pour les ecritures sub-millisecond. Detail rare qui evite un bug de pagination futur. + +8. **`AuditLogResource` est read-only stricte** (aucun POST/PUT/PATCH/DELETE) — Conforme au caractere append-only documente. Le 405 est automatique. + +### Les 10 regles a graver (tirees des findings) + +1. **Ne jamais laisser `/api/docs` publique en prod** — c'est une carte offerte gratuitement a un attaquant. +2. **Toujours poser les en-tetes de securite de base** (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS) — 3 lignes de Nginx, impact enorme. +3. **Toujours typer les parametres DBAL** (`Types::DATETIMETZ_IMMUTABLE` et compagnie) — passer une string brute a une colonne typee est un bug en attente. +4. **LIKE/ILIKE avec input utilisateur = toujours clause `ESCAPE` explicite** — ne pas se fier au comportement par defaut. +5. **`instanceof` + comportement "OK si pas du bon type" = faille** — une absence de type doit lever, pas passer. +6. **Tout `await` dans un callback qui modifie un flag singleton = `try/finally`** — sinon un throw bloque le flag. +7. **Toujours poser `paginationMaximumItemsPerPage`** sur les ressources exposees — sinon c'est un DoS en un query param. +8. **`JSON.stringify` sur donnees externes = toujours try/catch** — les objets circulaires existent. +9. **Cles i18n doivent suivre le namespace du module owner** (`sidebar..*`) — sinon on accumule des cles orphelines. +10. **`final` par defaut sur les services applicatifs** — ouverture a l'heritage = decision explicite, pas oublie. + +--- + +## 8. Resume par priorite + +| Priorite | Section | Probleme | Fichier | +|----------|---------|----------|---------| +| **P0** | 1.1 | `/api/docs` accessible public en prod | `config/packages/security.yaml:46` | +| **P0** | 1.2 | Aucun en-tete de securite HTTP en prod | `infra/prod/nginx.conf`, `nginx-proxy.conf` | +| **P0** | 1.3 | `robots.txt` autorise l'indexation | `frontend/public/robots.txt` | +| **P1** | 1.4 | `performed_at` sans typage → crash 500 | `AuditLogProvider.php:182-186` | +| **P1** | 1.5 | ILIKE sans clause `ESCAPE` | `AuditLogProvider.php:177-180` | +| **P1** | 1.6 | `SiteAwareInjectionProcessor` bypass silencieux | `SiteAwareInjectionProcessor.php:71` | +| **P1** | 1.7 | `isHandlingUnauthorized` sans try/finally | `useApi.ts:125-130` | +| **P1** | 1.8 | `itemsPerPage:999` no-op + pas de cap | `UserRbacDrawer.vue:235-236`, `RoleDrawer.vue:149`, `sites.vue:117` | +| **P1** | 2.1 | `JSON.stringify` sans garde | `AuditLogDetail.vue` | +| **P2** | 2.2 | Log manquant si JSON body invalide | `UserRbacProcessor.php:241-248` | +| **P2** | 3.1 | ` +``` + +```html + + +``` + +Si `MalioButton` n'a pas de variant adapte au rendu "lien inline" actuel, garder le `