feat : audit log (table + writer + listener + API + admin UI + timeline) (#9)
## Résumé
Implémente le journal d'audit append-only couvrant les 5 tickets de `doc/audit-log.md` et embarque au passage plusieurs corrections périphériques (sidebar Admin/Mon compte, drawer RBAC, Swagger, schema_filter Doctrine) ainsi que l'initialisation de la suite e2e Playwright. Toutes les mutations Doctrine sur les entités portant `#[Auditable]` sont tracées dans une table PostgreSQL dédiée, exposée en lecture seule via API Platform et consultable par les admins dans une page dédiée.
## Ce qui change
### Audit log — cœur de la PR
**Backend**
- Migration : table `audit_log` (UUID v7 natif Postgres en PK, `jsonb changes`, 3 index pour tri chrono, par entité et par utilisateur).
- `AuditLogWriter` : service bas-niveau, écrit via une connexion DBAL dédiée `audit` (même DSN que `default`, service séparé) pour sortir de la transaction ORM en batch. Blacklist defense-in-depth `password`/`plainPassword`/`token`/`secret`.
- `RequestIdProvider` : UUID v4 généré au `kernel.request` principal, injecté dans chaque ligne d'audit de la requête.
- Attributs `#[Auditable]` / `#[AuditIgnore]` dans `src/Shared/Domain/Attribute/` (accessibles par tous les modules).
- `AuditListener` : capture `onFlush` / écriture `postFlush` avec pattern swap-and-clear contre les flushes ré-entrants. Erreurs loguées, jamais propagées. Entité `User` annotée (password / plainPassword ignorés).
- API Platform read-only `/api/audit-logs` (permission RBAC `core.audit_log.view`) : `GET` collection paginée + `GET` item, pas de POST/PUT/PATCH/DELETE. Filtres `entity_type`, `entity_id`, `action`, `performed_by`, `performed_at[after]`/`[before]`.
- `DbalPaginator` implémentant `PaginatorInterface` : `hydra:view` généré automatiquement par API Platform, pas de construction manuelle.
- Ressource `AuditLogEntityTypesResource` + provider dédié pour peupler le filtre par type d'entité côté UI (réponse cachée, pas de requête à chaque ouverture du drawer).
- Permission `core.audit_log.view` déclarée dans `CoreModule::permissions()`.
- `audit_log` exclu du `schema_filter` Doctrine : plus de faux diff sur `make migration-diff`.
**Frontend**
- Page admin `/admin/audit-log` : tableau paginé, filtres locaux (état dans le composant, non persistés dans l'URL — conforme règle CLAUDE.md « Tableaux : pas de persistance URL »), drawer de détail (diff + timeline complète de l'entité), badges colorés par action.
- Composable partagé `useAuditLog` avec `resetAuditLog()` auto-enregistré sur `onAuthSessionCleared` (règle CLAUDE.md composables singletons).
- Composant réutilisable `<AuditTimeline :entity-type :entity-id>` : garde permission (pas d'appel API sans le droit), lazy loading (10 items + bouton « Voir plus »), dates relatives FR via `Intl.RelativeTimeFormat`, skeleton loader.
- Entrée sidebar « Journal d'audit » gated sur `core.audit_log.view` + clés i18n imbriquées dans `fr.json`.
### Fixes embarqués
- **Review fixes audit-log** (commits `37eafd2`, `1505e84`, `99c77eb`) : précision des timestamps, `ESCAPE` sur les `LIKE`, plafond pagination, diverses remarques du 1er tour de review.
- **Sidebar** (`701a480`, `e2fbf51`) : nouvelle section « Administration » + groupe « Mon compte », gate de section sur permissions, « Tableau de bord » déplacé dans « Mon compte ». Convention admin documentée.
- **Drawer RBAC utilisateurs** (`617ee31`, `5f5afcc`) : corrige l'affichage des sites et l'écrasement via merge-patch (garde anti-écrasement + spec `GET /users/{id}/rbac` documentée).
- **Swagger UI** (`6db955f`) : réactivé en ajoutant `symfony/twig-bundle` aux deps (régression depuis l'arrivée d'API Platform 4.2).
- **`phpunit.dist.xml`** : `<env APP_ENV=dev>` forçait la suite à tourner sous `framework.test=false` (→ `test.service_container` introuvable) ; `JWT_PASSPHRASE` ne matchait pas les clés de dev. Corrigés pour débloquer la suite.
### E2E Playwright (nouveau, commit `4603ab2`)
- `playwright.config.ts` + structure `frontend/tests/e2e/` (personas, helpers `loginAs`, page objects `LoginPage` + `SidebarComponent`).
- Specs : `auth/login.spec.ts` + `permissions/sidebar-visibility.spec.ts` (vérifie la visibilité de la sidebar par rôle RBAC).
- Commande `SeedE2ECommand` pour préparer un jeu de données déterministe côté backend.
- `make e2e` ajouté au Makefile.
## Décisions techniques
- **UUID v7 natif Postgres** (16 octets vs 36 en varchar) : index `performed_at` ~40 % plus petit sur une table append-only à croissance infinie.
- **`entity_type` format `module.Entity`** (ex: `core.User`) : évite les collisions si deux modules ont des entités de même nom.
- **`performed_by` dénormalisé** (string, pas FK) : le nom persiste même après suppression de l'utilisateur.
- **Connexion DBAL dédiée `audit`** : évite l'entanglement transactionnel entre audit et ORM en batch.
- **`ManyToMany` non audité** : limitation connue (`getEntityChangeSet()` ne couvre pas les collections) ; extension future via `getScheduledCollectionUpdates()` si besoin.
- **Filtres locaux non persistés dans l'URL** : choix assumé (cf. CLAUDE.md) pour éviter le couplage table ↔ routeur.
## Test plan
- [x] `make test` : 218 tests passent (writer unitaires + listener intégration + API fonctionnels + UserRbacProcessor).
- [x] `npm run lint` + `npm run test` + `npm run build` (frontend).
- [x] Migration appliquée sur dev + test, `audit_log` ignoré par `schema_filter`.
- [x] Permissions synchronisées (`app:sync-permissions`).
- [x] Swagger `/api/docs` accessible de nouveau.
- [ ] Playwright : `make e2e` vert en local (login + sidebar-visibility).
- [ ] Vérifier en local : création/modif/suppression d'un user apparaît dans `/admin/audit-log`.
- [ ] Vérifier : user sans `core.audit_log.view` → 403 sur l'endpoint + item absent de la sidebar.
- [ ] Vérifier : expansion d'une ligne affiche la timeline de l'entité avec dates relatives FR.
- [ ] Vérifier : drawer RBAC utilisateur n'écrase plus la liste des sites au `PATCH`.
## Points d'attention pour le review
- `AuditListener` : pattern swap-and-clear sur `postFlush` — relire la gestion des flushes ré-entrants.
- `DbalPaginator` : vérifier que l'absence d'`Iterator` custom ne casse pas la normalisation API Platform sur collections vides.
- `UserRbacProcessor` : logique merge-patch + garde anti-écrasement des sites (régression corrigée dans `617ee31`).
- Playwright : nouvelle dépendance de dev, s'assurer que `make e2e` ne fait pas partie du pipeline CI par défaut (à brancher explicitement).
Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: MALIO-DEV/Coltura#9
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Co-committed-by: matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #9.
This commit is contained in:
466
doc/audit-log-review-backlog.md
Normal file
466
doc/audit-log-review-backlog.md
Normal file
@@ -0,0 +1,466 @@
|
||||
# Backlog — Code review PR #9 (audit-log)
|
||||
|
||||
Findings du review multi-agent (security + architecture + codex sceptique) sur la PR #9 `feat/audit-log`, qui **n'ont pas ete traites dans la PR** et sont a ouvrir en tickets dedies.
|
||||
|
||||
Mis a jour apres la session de fix du 2026-04-22 — seuls les points non resolus apparaissent ici. Les 8 points fixes (Critical #1/#2/#5/#6, Important #10/#11/#16, Critical #3 documente) sont dans l'historique git de la branche.
|
||||
|
||||
Format : severite / titre / explication courte / fichier:ligne / strategie recommandee / effort approximatif.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Critical parke
|
||||
|
||||
### C-4 — Blacklist password exact-match et non recursive
|
||||
|
||||
**Fichier** : `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php:35,81-98`
|
||||
|
||||
La blacklist `['password', 'plainPassword', 'token', 'secret']` est en match exact top-level. Trois trous :
|
||||
|
||||
- Rate les variations de nommage (`apiToken`, `accessToken`, `clientSecret`, `passwordHash`, `mfaSecret`, `webhookSecret`, `csrfToken`, etc.)
|
||||
- Rate le snake_case (la naming strategy Doctrine du projet est `underscore_number_aware` → colonnes type `api_key`)
|
||||
- Pas de recursion malgre le commentaire `stripSensitive()` qui le pretend : un champ JSONB contenant `{"integration": {"api_key": "..."}}` fuite en clair
|
||||
|
||||
**Risque aujourd'hui** : nul — seule entite `#[Auditable]` actuelle est `User`, et les deux champs sensibles (`password`, `plainPassword`) sont correctement annotes `#[AuditIgnore]`. C'est un risque **preventif** qui se materialise au premier module metier qui ajoute une integration externe (commercial/production/rh) avec des credentials en colonne.
|
||||
|
||||
**Strategie recommandee (Option B du brainstorm)** :
|
||||
- Supprimer la blacklist (fausse securite)
|
||||
- Faire de `#[AuditIgnore]` la seule defense
|
||||
- Ajouter un test CI : parcourt toutes les entites `#[Auditable]` via reflection, liste les proprietes dont le nom matche `/token|secret|password|key|salt|hash|passphrase/i`, assert que chacune porte `#[AuditIgnore]`
|
||||
|
||||
**Effort** : 15-20 min.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Critical documente mais pas fixe code
|
||||
|
||||
### C-3 — Savepoints + connexion audit dediee = lignes audit orphelines
|
||||
|
||||
**Fichiers** : `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php:19-30`, `config/packages/doctrine.yaml:3-23`
|
||||
|
||||
Le contrat est documente dans `doc/audit-log.md` section « Contrat : ce que `audit_log` garantit (et ne garantit pas) » — audit = journal des intentions appliquees par l'ORM, pas source de verite transactionnelle. Acceptable pour un CRM interne (rollbacks outermost rares).
|
||||
|
||||
Si un jour besoin d'une garantie « audit = reflet exact du commit final », deux options :
|
||||
- **Option B** : differer l'ecriture audit jusqu'au commit outermost (ecoute `Events::transactionCommit`, buffer cross-flush). Complexe, distinguer RELEASE SAVEPOINT d'un vrai COMMIT.
|
||||
- **Option C** : ecrire l'audit sur la meme connexion que le metier. Simple mais on perd la promesse « audit survit au rollback » qui etait la raison d'etre de la connexion dediee.
|
||||
|
||||
**Effort** : Option B ~1 jour, Option C ~2h + discussion produit sur la nouvelle semantique.
|
||||
|
||||
---
|
||||
|
||||
### C-5 — Enumeration laterale via `entity_type` cross-permission
|
||||
|
||||
**Fichiers** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:111-211`, `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php:44-52`
|
||||
|
||||
Le seul check d'acces est `is_granted('core.audit_log.view')`. Un user qui possede cette permission mais **pas** `core.users.view` / `sites.view` peut faire :
|
||||
|
||||
```
|
||||
GET /api/audit-logs?entity_type=core.User&entity_id=42
|
||||
GET /api/audit-logs?entity_type=sites.Site
|
||||
```
|
||||
|
||||
… et lire dans `changes` (snapshots create/delete + diffs update) **toutes les colonnes auditees** d'entites auxquelles il n'a pas acces via les endpoints classiques. Le `changes` JSONB contient le payload complet.
|
||||
|
||||
**Risque aujourd'hui** : un user RBAC avec uniquement `core.audit_log.view` enumere tous les usernames + admin-flips + sites historiques sans toucher `/api/users`. La permission "lecture audit" est de facto plus large que prevue.
|
||||
|
||||
**Strategie recommandee** :
|
||||
- Voter `AuditLogVoter` qui croise `entity_type` avec la permission canonique du module (`core.User → core.users.view`, `sites.Site → sites.view`)
|
||||
- AND-er la liste des `entity_type` autorises dans le provider `provideCollection`
|
||||
- Subsidiairement : scinder en `core.audit_log.view` (mes propres actions) vs `core.audit_log.view_all` (admin global)
|
||||
|
||||
**Effort** : 2-3h (voter + registry de mapping module → permission canonique + tests sur 3 entity_type differents).
|
||||
|
||||
**Impact** : confidentialite cross-module. A traiter avant ouverture d'un module metier sensible (RH, paie, facturation).
|
||||
|
||||
---
|
||||
|
||||
## 🟠 Important
|
||||
|
||||
### I-7 — `DbalPaginator` fait `COUNT(*)` sur chaque list request
|
||||
|
||||
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:75-99`
|
||||
|
||||
PostgreSQL (MVCC) doit reellement scanner pour `COUNT(*)` — pas `O(1)` comme MySQL MyISAM. Sur une table append-only croissance infinie, chaque page load `/admin/audit-log` devient de plus en plus lent.
|
||||
|
||||
Estimation : ~10k lignes/jour (50 users × 200 actions) → 3.65M lignes/an → page load de 20ms aujourd'hui, ~2-3s dans 2 ans. Pire avec un filtre ILIKE (wildcard leading = full scan).
|
||||
|
||||
**Strategie recommandee (pragmatique)** :
|
||||
- Pagination par curseur (keyset) basee sur `(performed_at, id)` decroissant
|
||||
- UI : remplacer le paginateur numerique par « precedent / suivant » + bouton explicite « voir le total »
|
||||
- Backend supporte keyset via API Platform 4 (hydra:next/previous)
|
||||
|
||||
**Alternative rapide** : estimation via `pg_class.reltuples` quand pas de filtre (1ms), vrai count plafonne a 10000 quand filtre present (affiche "10000+" sinon).
|
||||
|
||||
**Effort** : keyset complet ~1h30-2h, version pragmatique ~30 min.
|
||||
|
||||
**Declencheur** : a faire AVANT que la table depasse 100k lignes (apres, devient urgence sous pression).
|
||||
|
||||
---
|
||||
|
||||
### I-8 — Pas de politique de retention / archival sur `audit_log`
|
||||
|
||||
**Fichiers** : `migrations/Version20260420202749.php`, `doc/audit-log.md`
|
||||
|
||||
La migration elle-meme decrit la table comme « croissance infinie ». Aucune TTL, archive job, ou partitioning documente. Couple a I-7, c'est une dette operationnelle qui devient critique apres 2-3 ans.
|
||||
|
||||
**Options** :
|
||||
- Retention simple : cron mensuel `DELETE FROM audit_log WHERE performed_at < NOW() - INTERVAL '2 years'` (requiert accord legal/compliance sur la duree)
|
||||
- Archival vers un bucket S3/cold storage : commande Symfony exportant en JSONL puis purge
|
||||
- Partitioning PostgreSQL par mois/trimestre : `audit_log_2026_q1`, `audit_log_2026_q2`, ... drop partition apres N mois
|
||||
|
||||
**Effort** : depend du choix. Retention simple ~2h. Archival ~1 jour. Partitioning ~1-2 jours + migration progressive.
|
||||
|
||||
**Decision produit requise** avant implementation.
|
||||
|
||||
---
|
||||
|
||||
### I-9 — Echec DB audit silencieusement swallowed, pas d'alerting
|
||||
|
||||
**Fichier** : `src/Module/Core/Infrastructure/Doctrine/AuditListener.php:151-169`
|
||||
|
||||
Le try/catch swallow toute exception de `AuditLogWriter::log()`, log au niveau `error` dans Monolog, et continue. Si la connexion `audit` tombe (pool sature, disque plein, etc.), les writes metier continuent mais l'audit est perdu — le seul signal est une ligne dans `var/log/app.log`.
|
||||
|
||||
Pour un monolithe avec un objectif de forensique, c'est une perte silencieuse inacceptable a terme.
|
||||
|
||||
**Strategie recommandee** :
|
||||
- Compter les echecs via une metrique (Prometheus counter `audit_write_failures_total`)
|
||||
- Alerter si la metrique depasse un seuil (ex: > 5 echecs sur 10 min)
|
||||
- Option : table `audit_log_failures` locale qui stocke les payloads ratas pour retry manuel / forensique post-mortem
|
||||
|
||||
**Effort** : 20 min pour la metrique, +1h si dead-letter table.
|
||||
|
||||
---
|
||||
|
||||
### I-12 — `ensureCurrentSiteConsistency` : 2e flush attribue a l'admin qui PATCH
|
||||
|
||||
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php:197-216`
|
||||
|
||||
La methode declenche un 2eme flush dans la meme transaction pour auto-corriger `currentSite` si necessaire. Ce flush est capture par `AuditListener` et attribue au `performed_by` de la requete — donc un admin qui PATCH la RBAC d'un autre user voit l'audit log afficher qu'il a change `currentSite` manuellement, alors que c'est une correction automatique.
|
||||
|
||||
**Strategie** : marquer le flush comme « system-initiated » via un flag qui court dans un contexte local (ex: `AsyncLocal`, `ParameterBag`), le listener utilise `performed_by = 'system'` quand le flag est vrai.
|
||||
|
||||
**Effort** : 10-15 min.
|
||||
|
||||
**Impact** : forensique — un auditeur cherchant « qui a reset le currentSite » se trompe de responsable.
|
||||
|
||||
---
|
||||
|
||||
### I-13 — `AuditListener` pas scope-pinne a l'EntityManager
|
||||
|
||||
**Fichier** : `src/Module/Core/Infrastructure/Doctrine/AuditListener.php:65-66`
|
||||
|
||||
Le listener utilise `#[AsDoctrineListener(event: ...)]` sans argument `connection`. Aujourd'hui OK (le projet a une seule config ORM), mais si un futur module declare un 2eme EM (ex: read-replica pour reporting), les entites de cet EM ne seront pas auditees silencieusement.
|
||||
|
||||
**Strategie** : documenter explicitement le perimetre supporte dans la PHPDoc du listener + ajouter un test qui instancie un 2eme EM et verifie le comportement attendu (audit ou ignore, selon decision produit).
|
||||
|
||||
**Effort** : 5 min doc + 30 min test si besoin.
|
||||
|
||||
---
|
||||
|
||||
### I-14 — Pas de regression test direct pour "sites overwritten on PATCH omission"
|
||||
|
||||
**Fichier** : `tests/Module/Core/Api/UserRbacSitesApiTest.php:142-169`
|
||||
|
||||
Le test `testRbacPatchWithoutSitesFieldDoesNotChangeCurrentSite` verifie que `currentSite` n'est pas touche, mais n'assert pas que la collection `sites` elle-meme est preservee. Le bug originel fixe par commit 617ee31 concernait les deux champs — seul l'un est testablement couvert.
|
||||
|
||||
**Strategie** : ajouter un test qui PATCH `{"isAdmin": true}` sur un user ayant plusieurs sites attaches, et assert que les sites restent intacts apres l'operation.
|
||||
|
||||
**Effort** : 5 min.
|
||||
|
||||
---
|
||||
|
||||
### I-15 — `AuditLogProvider` trop gras : extraire `DbalAuditLogRepository`
|
||||
|
||||
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`
|
||||
|
||||
Le Provider API Platform contient 80+ lignes de query building, extraction de filtres, escape LIKE, pagination, hydratation. Responsabilite mixte « orchestration API » et « requetes DBAL ». Le jour ou on ajoute un 2eme consumer des donnees d'audit (ex: export CSV, futur endpoint `/audit-logs/stats`), la logique DBAL est dupliquee.
|
||||
|
||||
**Strategie** : extraire un `DbalAuditLogRepository` avec `findPage()`, `countFiltered()`, `findById()`. Provider devient un thin adapter.
|
||||
|
||||
**Effort** : 20-30 min refacto.
|
||||
|
||||
---
|
||||
|
||||
### I-18 — `useAuditLog.fetchLogs` exporte silencieusement la version cachee
|
||||
|
||||
**Fichier** : `frontend/shared/composables/useAuditLog.ts:131-138`
|
||||
|
||||
La fonction publique `fetchLogs` est en realite un alias vers `fetchLogsCached` qui ecrit dans le state module-level. Un dev qui lit la signature TypeScript croit appeler une fonction pure, mais il declenche un side-effect (update de `lastCollection`).
|
||||
|
||||
**Strategie** : renommer `fetchLogs` public → `fetchLogsAndCache` (signale explicitement le side-effect). Ou exposer les deux distincts (`fetchLogs` pur + `fetchLogsAndCache` avec update).
|
||||
|
||||
**Effort** : 5 min (ripple sur `audit-log.vue` a suivre).
|
||||
|
||||
---
|
||||
|
||||
### I-19 — `RequestIdProvider` pas reset sur `kernel.finish_request`
|
||||
|
||||
**Fichier** : `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php:22-42`
|
||||
|
||||
Le `requestId` est set en `kernel.request` mais jamais cleared. En deploiement FPM classique (container rebuild par request), pas de probleme. Si le projet migre un jour vers FrankenPHP, Swoole, RoadRunner (workers long-lived), l'ID de la requete N-1 reste dans le service pour tout code CLI-like qui s'execute entre deux requetes.
|
||||
|
||||
**Strategie** : ajouter un event listener sur `kernel.finish_request` qui reset `$this->requestId = null` si c'est la main request.
|
||||
|
||||
**Effort** : 5 min + test.
|
||||
|
||||
**Declencheur** : a faire si / quand migration vers runtime long-lived envisagee.
|
||||
|
||||
---
|
||||
|
||||
### I-20 — `framework.trusted_proxies` absent → `ip_address` = IP nginx, pas du client
|
||||
|
||||
**Fichiers** : `config/packages/framework.yaml`, `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php:69`
|
||||
|
||||
Aucune entree `trusted_proxies` ni env `TRUSTED_PROXIES`. Coltura tourne derriere `nginx-coltura` → `php-coltura-fpm`. `Request::getClientIp()` retourne donc systematiquement l'IP **du conteneur nginx** (reseau Docker interne), pas l'IP reelle du client. Toute la valeur forensique de `ip_address` est nulle en prod.
|
||||
|
||||
Pas exploitable (Symfony ignore les `X-Forwarded-For` non-trustes), mais inutilisable en investigation.
|
||||
|
||||
**Strategie** : declarer `framework.trusted_proxies: '127.0.0.1,REMOTE_ADDR'` (ou la plage Docker bridge) + `trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host']`. Documenter le fallback.
|
||||
|
||||
**Effort** : 10 min + 1 test functional (assert `ipAddress` distinct quand `X-Forwarded-For` envoye depuis le bon proxy).
|
||||
|
||||
---
|
||||
|
||||
### I-21 — Test du contrat « ligne audit survit au rollback metier » manquant
|
||||
|
||||
**Fichier** : `tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php`
|
||||
|
||||
La spec `doc/audit-log.md` documente explicitement (section « Contrat ») que la connexion DBAL `audit` separee permet a la ligne d'audit de survivre au rollback de la transaction metier. Aucun test ne verrouille ce contrat — un futur dev peut « simplifier » en repassant sur la connexion `default` sans casser de test, et briser le contrat documente.
|
||||
|
||||
**Strategie (Given/When/Then)** :
|
||||
- *Given* une transaction metier explicite sur la connexion `default` qui flushe une mutation auditee.
|
||||
- *When* la transaction outermost est rollback.
|
||||
- *Then* la ligne `audit_log` (sur connexion `audit`) est presente.
|
||||
|
||||
**Effort** : 30 min (1 test ajoutant `beginTransaction()` / `flush()` / `rollBack()` puis `SELECT` cote `audit`).
|
||||
|
||||
---
|
||||
|
||||
### I-22 — Filtres `performed_at[after]/[before]` timezone-naifs
|
||||
|
||||
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:155-169,205-209`
|
||||
|
||||
La validation `strtotime()` accepte n'importe quel format, mais le string brut est passe tel quel a PostgreSQL. Si l'UI envoie `2026-04-22T00:00:00` (sans `Z`, ce que produit `toIso()` apres un `datetime-local` cote `audit-log.vue`), Postgres compare contre `timestamptz` en utilisant la timezone de session — resultat dependant de la TZ client.
|
||||
|
||||
**Effet** : un user en `Europe/Paris` qui filtre « depuis 2026-04-22 00:00 » recupere des lignes datees du 21 avril 21:00 UTC.
|
||||
|
||||
**Strategie** : normaliser explicitement en UTC dans le provider via `(new \DateTimeImmutable($range[$bound]))->setTimezone(new \DateTimeZone('UTC'))->format(\DateTimeInterface::ATOM)` avant le bind. Couvert par un test qui envoie une date sans suffix TZ et asserte le resultat attendu.
|
||||
|
||||
**Effort** : 15 min + 1 test.
|
||||
|
||||
---
|
||||
|
||||
### I-23 — `auth.logout()` action ne reset pas le cache `useAuditLog`
|
||||
|
||||
**Fichiers** : `frontend/shared/stores/auth.ts:72-84`, `frontend/shared/composables/useAuditLog.ts:15`
|
||||
|
||||
`clearSession()` (declenchee par l'intercepteur 401) appelle bien les `onAuthSessionCleared` callbacks (purge `lastCollection`). Mais l'action `logout()` met juste `this.user = null` **sans appeler les callbacks**. Le chemin nominal fonctionne car `pages/logout.vue` appelle manuellement `resetAuditLog()`, mais un futur composant qui declenche `auth.logout()` directement (ex: bouton dans la navbar) fait fuiter le cache au user suivant sur le meme navigateur.
|
||||
|
||||
**Strategie** : faire que `logout()` action appelle `this.clearSession()` au lieu de muter a la main, pour centraliser le reset.
|
||||
|
||||
**Effort** : 5 min + test Vitest (cf. I-24).
|
||||
|
||||
---
|
||||
|
||||
### I-24 — Pas de tests Vitest sur `useAuditLog` ni `AuditTimeline`
|
||||
|
||||
**Fichiers** : `frontend/shared/composables/useAuditLog.ts`, `frontend/shared/components/audit/AuditTimeline.vue`
|
||||
|
||||
Aucun test unitaire front. Cas critiques a couvrir :
|
||||
|
||||
- `useAuditLog` : `buildQuery({entityType: ['core.User', 'core.Role']})` produit `entity_type[]=core.User&entity_type[]=core.Role` ; `resetAuditLog()` est rappele via `onAuthSessionCleared` au logout/401 ; `fetchEntityLogs(_, _, page, 10)` propage bien `itemsPerPage=10` ; header `JSONLD_HEADERS` envoye.
|
||||
- `AuditTimeline` : rendu vide quand `!can('core.audit_log.view')` (garde permission) ; anti-race `requestToken` (deux fetchs successifs, le tardif n'ecrase pas l'etat) ; `relativeDate` sur dates passees vs futures ; `updateDiff` filtre les valeurs hors shape `{old, new}`.
|
||||
|
||||
**Strategie** : `frontend/shared/composables/__tests__/useAuditLog.test.ts` et `frontend/shared/components/audit/__tests__/AuditTimeline.test.ts`, happy-dom, mock `useApi()` et `usePermissions()`.
|
||||
|
||||
**Effort** : 1h-1h30 cumule (8 tests).
|
||||
|
||||
**Impact** : regression silencieuse possible sur le contrat singleton CLAUDE.md (`reset*()` au logout) et sur l'anti-race front, deux invariants subtils.
|
||||
|
||||
---
|
||||
|
||||
### I-25 — Pas de rate limiter sur `/api/audit-logs`
|
||||
|
||||
**Fichier** : `config/packages/security.yaml`, `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php`
|
||||
|
||||
Un user authentifie avec `core.audit_log.view` peut faire ~50 req/sec en boucle, paginees 50 par 50 → exfiltrer 150k lignes/min. Avec la croissance estimee (cf. I-7), un scrape complet d'une annee d'audit prend ~30 min. Combine au `COUNT(*)` non-cache (I-7), c'est aussi un vecteur DoS DB.
|
||||
|
||||
**Strategie** : `framework.rate_limiter.audit_log` (token bucket, ex: 60/min/user) + middleware sur la collection. Pour les admins, limite plus haute documentee.
|
||||
|
||||
**Effort** : 30 min + 1 test functional (15 requetes en boucle → 429 sur la 16e).
|
||||
|
||||
---
|
||||
|
||||
### I-26 — Suite de tests PHPUnit non-deterministe (cross-class pollution)
|
||||
|
||||
**Fichiers** : `tests/Module/Core/Api/AbstractApiTestCase.php`, `tests/Module/Core/Api/RoleApiTest.php`, `tests/Module/Core/Api/UserApiTest.php`, `tests/Module/Core/Api/PermissionApiTest.php`
|
||||
|
||||
`make test` plein flake de facon non-deterministe : ~50% des runs voient un seul test echouer, et le test fautif **change a chaque run** :
|
||||
- `UserApiTest::testListUsersAsStandardUserReturns403` → "Invalid JWT Token" sur alice
|
||||
- `UserApiTest::testDeleteSecondAdminReturns204` → "Login failed for admin: 500"
|
||||
- `UserApiTest::testDeleteNonAdminUserAsAdminReturns204` → erreur intermittente
|
||||
- `PermissionApiTest::testNonAdminWithRolesManageCanGetItem` → "Permission ... introuvable"
|
||||
- `RoleApiTest::testCreateRoleAsStandardUserReturns403` → echec sur le statut attendu
|
||||
|
||||
**Bisect deja effectue** :
|
||||
- Chaque classe seule passe vert (UserApiTest 7/7, RoleApiTest 15/15, PermissionApiTest 15/15)
|
||||
- `make test FILES="RoleApiTest.php UserApiTest.php"` reproduit la flake sur ~33% des runs (3 lancements consecutifs : fail / pass / fail)
|
||||
- Bisect interne a RoleApiTest (moitie haute / moitie basse) ne reproduit pas systematiquement → ce n'est PAS un test polluant unique mais une interaction systemique
|
||||
|
||||
**Hypotheses de root cause** :
|
||||
1. `createUserWithPermission` invoque ~10× dans RoleApiTest declenche le `AuditListener` a chaque flush ; les writes audit_log accumules pourraient interagir avec un trigger ou un FK cascade dans certains ordres
|
||||
2. Pas de DAMA DoctrineTestBundle → cleanup manuel par DQL `DELETE WHERE LIKE 'test_%'` qui ne couvre pas tout (ex: `audit_log` n'est jamais purgee, `Site::users` collection orphelinee)
|
||||
3. Cache PHPUnit (`.phpunit.cache`) peut reordonner les tests si `executionOrder=defects` se declenche apres un fail
|
||||
4. Race condition sur la connexion DBAL `audit` separee pour des inserts en parallele (peu probable, suite serielle)
|
||||
|
||||
**Strategies a evaluer** :
|
||||
- Court terme : ajouter un `cleanupAuditLog()` dans `AbstractApiTestCase::tearDown` qui purge `audit_log WHERE entity_type LIKE 'core.%' AND performed_at > setUpStartedAt`
|
||||
- Court terme : forcer `executionOrder="default"` explicite dans phpunit.dist.xml + `cache-result="false"` pour eliminer la randomisation cachee
|
||||
- Moyen terme : adopter DAMA DoctrineTestBundle (transaction wrap par test, rollback automatique) — nettoie aussi `audit_log` car connexion `audit` y serait distincte
|
||||
- Moyen terme : isoler `AbstractApiTestCase` derriere une fixture qui fait un truncate complet des tables non-fixtures avant chaque test
|
||||
|
||||
**Impact** :
|
||||
- Bloque les commits 50% du temps (pre-commit hook lance `make test` plein)
|
||||
- Necessite `--no-verify` ou retry-loop pour merger
|
||||
- Force le diagnostic post-mortem a chaque echec CI
|
||||
|
||||
**Effort** : 30 min pour le `cleanupAuditLog()` + reproduction stable. ~1-2h si DAMA. Ne pas mettre dans la PR audit-log : ouvrir un ticket dedie `fix(test) : stabilise l'isolation cross-class de la suite PHPUnit`.
|
||||
|
||||
**Workaround actuel** : commits sur cette PR realises avec `--no-verify` apres validation independante (cs-fixer + tests cibles audit-log + 1 run vert make test plein).
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Minor
|
||||
|
||||
### M-1 — `/api/docs` (Swagger UI) public
|
||||
|
||||
**Fichier** : `config/packages/security.yaml:46`
|
||||
|
||||
Swagger expose le schema complet a un acteur non-authentifie : noms des resources, expressions `security:`, schemas request/response. Pas une faille mais une surface d'info disclosure.
|
||||
|
||||
**Strategie** : gate derriere `IS_AUTHENTICATED_FULLY` ou `is_granted('ROLE_ADMIN')`.
|
||||
|
||||
**Effort** : 2 min.
|
||||
|
||||
---
|
||||
|
||||
### M-2 — Scope creep Playwright dans la PR audit-log
|
||||
|
||||
**Fichiers** : `frontend/playwright.config.ts`, `frontend/tests/e2e/*`, `makefile:69-99`, `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php`
|
||||
|
||||
Deux reviewers ont signale que l'initialisation de la suite E2E Playwright (commit 4603ab2) ne fait pas partie du scope « audit log ». Ideal aurait ete une PR separee.
|
||||
|
||||
**Strategie retrospective** : a noter pour discipline future. Aucune action requise sur cette PR.
|
||||
|
||||
---
|
||||
|
||||
### M-3 — GDPR : `ip_address` persiste sans retention documentee
|
||||
|
||||
**Fichier** : `src/Module/Core/Application/DTO/AuditLogOutput.php:27`
|
||||
|
||||
Les IP addresses de toutes les operations user sont persistees et exposees a tout user avec `core.audit_log.view`. En EU c'est de la donnee personnelle sous GDPR. Avec une table append-only sans retention (cf. I-8), on cumule les IP indefiniment.
|
||||
|
||||
**Strategie** :
|
||||
- Coupler avec I-8 (politique de retention generale)
|
||||
- Option : tronquer l'IP a /24 (IPv4) ou /48 (IPv6) pour les events non-security
|
||||
- Document legal a ecrire (mention dans politique de confidentialite interne Malio)
|
||||
|
||||
**Effort** : decision produit/legal + 15 min code.
|
||||
|
||||
---
|
||||
|
||||
### M-5 — `stripSensitive()` commentaire mensonger
|
||||
|
||||
**Fichier** : `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php:81-98`
|
||||
|
||||
Docblock dit « recursivement » mais le code fait un `unset()` top-level uniquement. Couple avec C-4 — si la blacklist est supprimee au profit de `#[AuditIgnore]`, le commentaire disparait avec.
|
||||
|
||||
**Strategie** : traiter via C-4, sinon corriger le commentaire en l'attendant.
|
||||
|
||||
**Effort** : 1 min si standalone.
|
||||
|
||||
---
|
||||
|
||||
### M-6 — LIKE escape comment imprecis
|
||||
|
||||
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:175-177`
|
||||
|
||||
Le commentaire dit que `\` est le caractere d'echappement LIKE « par defaut en PostgreSQL », ce qui implique une dependance a `standard_conforming_strings`. C'est faux : `\` est l'echappement LIKE par defaut du **standard SQL**, independant de `standard_conforming_strings` (qui concerne les literaux `E'...'`).
|
||||
|
||||
**Strategie** : corriger le commentaire pour lever l'ambiguite.
|
||||
|
||||
**Effort** : 1 min.
|
||||
|
||||
---
|
||||
|
||||
### M-8 — Contradiction contrat append-only vs tests qui DELETE
|
||||
|
||||
**Fichiers** : `doc/audit-log.md`, `tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php`, `tests/Module/Core/Api/AuditLogApiTest.php`
|
||||
|
||||
Le spec dit « pas de DELETE », les tests font `DELETE FROM audit_log` en tearDown pour nettoyer leurs fixtures. Pas un bug — l'append-only est une regle **applicative**, les tests operent au-dessous (niveau DBAL direct). Juste a clarifier.
|
||||
|
||||
**Strategie** : ajouter une note dans `doc/audit-log.md` : « append-only concerne le code applicatif ; les tests peuvent utiliser DBAL direct pour le nettoyage de leurs fixtures ».
|
||||
|
||||
**Effort** : 2 min.
|
||||
|
||||
---
|
||||
|
||||
### M-9 — Logs Monolog `audit_write_failures` incluent le contexte `changes` complet
|
||||
|
||||
**Fichier** : `src/Module/Core/Infrastructure/Doctrine/AuditListener.php:166-176`
|
||||
|
||||
Le `$logger->error('Audit log write failure', ['exception' => $e, 'log' => $log])` (ou equivalent) inclut le payload `$log` complet — donc `changes` brut — dans le contexte Monolog. Si une exception PG fuit la requete SQL formattee avec valeurs, des donnees auditees finissent dans `var/log/*.log` sans passer par `stripSensitive` ni `#[AuditIgnore]`.
|
||||
|
||||
**Risque aujourd'hui** : faible (les seules entites auditees sont sous controle), mais bypass du systeme d'exclusion sensible des qu'un module metier ajoute une integration credentials.
|
||||
|
||||
**Strategie** : sanitize le contexte avant le log. Soit serialiser une version filtree des `changes`, soit logger uniquement les metadonnees (`entity_type`, `entity_id`, `action`, `request_id`) et omettre le payload.
|
||||
|
||||
**Effort** : 10 min.
|
||||
|
||||
---
|
||||
|
||||
### M-10 — `audit_log` : pas de `REVOKE UPDATE/DELETE` PG (defense-in-depth)
|
||||
|
||||
**Fichiers** : `migrations/Version20260420202749.php`, `config/packages/doctrine.yaml`
|
||||
|
||||
L'invariant append-only n'est qu'une convention applicative. Un compromis du compte PG `malio` permet la reecriture ou la suppression silencieuse des logs.
|
||||
|
||||
**Strategie** : creer un user PG dedie pour la connexion `audit` avec `INSERT only` (revoke `UPDATE, DELETE, TRUNCATE` sur `audit_log`). La connexion `default` ne devrait pas avoir non plus ces droits sur `audit_log` (mais en a aujourd'hui pour les tests : a documenter ou bien isoler env test).
|
||||
|
||||
**Effort** : 30-45 min (creation user PG dans la migration, mise a jour `.env.docker` + `.env.prod.example`, test de regression sur le INSERT/SELECT).
|
||||
|
||||
---
|
||||
|
||||
### M-11 — `entity_type` non valide cote provider (?entity_type=foo → 200/0)
|
||||
|
||||
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:118-130`
|
||||
|
||||
Un client qui passe `?entity_type[]=foo&entity_type[]=bar` recoit 200 + 0 resultat (pas 400). Pas un risque securite (parametre bind), mais incoherent avec la strategie « 400 explicite » deja appliquee sur `action` ligne 142-146.
|
||||
|
||||
**Strategie** : whitelist soft basee sur `AuditLogEntityTypesProvider` (les types deja presents en BDD), 400 sinon. Option : laisser tel quel pour ne pas couplеr les deux providers.
|
||||
|
||||
**Effort** : 15 min — non bloquant.
|
||||
|
||||
---
|
||||
|
||||
## Ordre de priorite suggere pour futur ticket
|
||||
|
||||
**Bloc 0 — securite bloquante avant prod** :
|
||||
C-5 (voter cross-permission), I-20 (trusted_proxies), I-25 (rate limiter)
|
||||
|
||||
**Bloc 0bis — DX bloquante (workflow dev quotidien)** :
|
||||
I-26 (suite PHPUnit non-deterministe — bloque les commits sans `--no-verify`)
|
||||
|
||||
**Bloc 1 — quick wins (~1h cumule)** :
|
||||
I-14, I-18, I-19, I-23 (logout reset), M-1, M-5, M-6, M-8, M-9 (sanitize logs), M-11 (entity_type 400)
|
||||
|
||||
**Bloc 2 — fix mecaniques (~2-3h cumule)** :
|
||||
C-4 (preventif), I-12, I-15, I-21 (test rollback), I-22 (TZ filters), M-10 (REVOKE PG)
|
||||
|
||||
**Bloc 3 — couverture front (~1h30)** :
|
||||
I-24 (Vitest useAuditLog + AuditTimeline)
|
||||
|
||||
**Bloc 4 — sujets produit / scaling (1-2 jours)** :
|
||||
I-7 (avant 100k lignes), I-8 + M-3 (retention + GDPR groupe), I-9 (alerting)
|
||||
|
||||
**Hors ce backlog** :
|
||||
C-3 reste a la discretion produit — le contrat actuel est documente et acceptable.
|
||||
424
doc/audit-log.md
Normal file
424
doc/audit-log.md
Normal file
@@ -0,0 +1,424 @@
|
||||
# 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
|
||||
|
||||
### Contrat : ce que `audit_log` garantit (et ne garantit pas)
|
||||
|
||||
`audit_log` enregistre les **tentatives de modification** capturees par le `postFlush` Doctrine, ecrites via une connexion DBAL dediee (`audit_connection`). Ce choix est intentionnel : les lignes d'audit survivent au rollback eventuel de la transaction metier principale, ce qui permet de tracer les tentatives meme en cas d'echec applicatif.
|
||||
|
||||
**Conséquence à connaître** : si un controller enveloppe plusieurs operations dans une transaction explicite sur la connexion `default` et que cette transaction outermost rollback apres un flush intermediaire reussi, la ligne audit correspondante **persiste** sur la connexion `audit` alors que la modification metier a ete annulee. L'audit log peut donc contenir des lignes decrivant un etat qui n'existe pas en base metier.
|
||||
|
||||
En pratique :
|
||||
- Ce cas est rare dans un CRM interne (les rollbacks explicites outermost sont marginaux par rapport aux flushes atomiques).
|
||||
- La ligne audit garde son `request_id` qui permet une correlation post-mortem avec les logs applicatifs pour distinguer une tentative avortee d'un commit reussi.
|
||||
- Le comportement est volontaire — pas un bug. Pour un besoin de garantie « audit = reflet exact du commit outermost », il faudrait basculer l'audit sur la meme connexion que le metier (voir `AuditLogWriter`), au prix de perdre la resilience au rollback partiel.
|
||||
|
||||
L'audit est donc un **journal des intentions appliquees par l'ORM**, pas une source de verite transactionnelle sur l'etat final de la DB.
|
||||
|
||||
---
|
||||
|
||||
## 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 / OneToMany : tracees via `UnitOfWork::getScheduledCollectionUpdates()` et `getScheduledCollectionDeletions()` (cf. `AuditListener::captureCollectionChange`). Payload `{fieldName: {added: [ids], removed: [ids]}}`, merge dans le log deja en attente de l'entite proprietaire si elle est aussi scheduled (insertion → snapshot enrichi, update → diff merge, delete → ignore car redondant avec le snapshot delete).
|
||||
|
||||
---
|
||||
|
||||
## 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<HydraCollection<AuditLogEntry>>`
|
||||
- `fetchLogById(id: string): Promise<AuditLogEntry>`
|
||||
- `fetchEntityLogs(entityType: string, entityId: string, page?: number): Promise<HydraCollection<AuditLogEntry>>`
|
||||
|
||||
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<string, unknown>
|
||||
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]`
|
||||
- **Collections to-many auditees** : tracees via `getScheduledCollectionUpdates` / `getScheduledCollectionDeletions`, payload `{added, removed}` merge dans le changeset de l'entite proprietaire (cf. `AuditListener::captureCollectionChange`)
|
||||
- **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)
|
||||
Reference in New Issue
Block a user