# 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`. Starseed tourne derriere `nginx-starseed` → `php-starseed-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.
