Compare commits

...

11 Commits

Author SHA1 Message Date
7c6103e395 docs(audit-log) : ajoute findings review additionnels au backlog
Ajoute :
- C-5 : enumeration laterale via entity_type cross-permission
- I-20 : framework.trusted_proxies absent → ip_address inutilisable
- I-21 : test du contrat rollback metier manquant
- I-22 : filtres performed_at[after|before] timezone-naifs
- I-23 : auth.logout() ne reset pas le cache useAuditLog
- I-24 : pas de tests Vitest sur useAuditLog ni AuditTimeline
- I-25 : pas de rate limiter sur /api/audit-logs
- I-26 : suite PHPUnit non-deterministe (cross-class pollution)
- M-9 : logs Monolog audit_write_failures incluent changes complet
- M-10 : audit_log sans REVOKE UPDATE/DELETE PG (defense-in-depth)
- M-11 : entity_type non valide cote provider

Retire M-4 et M-7 (traites dans cette serie de commits).
Reorganise la priorite avec un Bloc 0 securite avant prod
et un Bloc 0bis DX bloquante.
2026-05-02 17:18:36 +02:00
b2f17b0522 test(audit-log) : ajoute 403 sur GET item et invariant page=0
- testItemEndpointWithoutPermissionGets403 : symetrique de
  testAuthenticatedUserWithoutPermissionGets403 sur /api/audit-logs/{id},
  prouve que la security expression `is_granted('core.audit_log.view')`
  est appliquee aussi sur l'operation Get item (couvre M-4 du backlog).
- testPageZeroDoesNotProduceServerError : verrouille l'invariant
  fonctionnel "?page=0 ne produit jamais 500 PG", quel que soit le
  mecanisme protecteur (clamp provider ou validation API Platform amont).
  Couvre M-7 du backlog.
2026-05-02 17:18:25 +02:00
c09501d321 fix(audit-log) : clamp page minimum a 1 dans AuditLogProvider
API Platform 4 valide deja `page >= 1` en amont (rejette en 400) avant que
le provider ne soit appele. Le clamp `max(1, $page)` reste en place comme
defense-in-depth si un futur upgrade ou une modification de configuration
leve cette validation : garantit qu'aucune `page=0` ne produira de
SQLSTATE[22023] OFFSET must not be negative.
2026-05-02 17:18:16 +02:00
2645a335c4 docs(audit-log) : corrige description ManyToMany dans la spec
AuditListener::captureCollectionChange utilise getScheduledCollectionUpdates
et getScheduledCollectionDeletions pour produire {fieldName: {added, removed}}.
La spec (lignes 197 et 418) annoncait l'inverse - alignement sur le code reel
et sur .claude/rules/backend.md.
2026-05-02 17:18:06 +02:00
fcc7a2e3d4 docs(audit-log) : ajoute backlog des findings review non-traites dans la PR
Resume structure des 9 Important + 8 Minor + C-4 parke + C-3 documente.

Chaque entree contient : severite, fichier:ligne, strategie recommandee,
effort estime, declencheur (quand / pourquoi faire). Trois blocs
priorises pour faciliter le picking en tickets (quick wins, mecaniques,
scaling produit).

Permet de ne pas perdre la vue d'ensemble du review et d'ouvrir les
tickets dedies avec contexte complet sans re-analyser le diff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:21:49 +02:00
269c232bfc chore(test) : supprime env APP_ENV redondant dans phpunit.dist.xml
Le <server name="APP_ENV" value="test" force="true"> fait deja le job ;
la ligne <env name="APP_ENV" value="test"/> en double creait un piege a
regression : un dev qui mettrait "dev" en pensant que <server> gere
tout, puis supprimerait <server>, verrait <env> reprendre la main
silencieusement et reintroduirait le bug framework.test=false (cf.
commit 37eafd2 du fix initial).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:21:49 +02:00
fa8e46471d fix(frontend) : auto-reset useSidebar et useModules sur 401/logout
Les deux composables ont un state singleton au niveau module mais
n'etaient reinitialises que dans logout.vue — un 401 silencieux (JWT
expire) laissait la sidebar et la liste de modules actifs de l'ancien
user visible jusqu'a ce qu'un nouveau login complete `loadSidebar()`.

Aligne le pattern sur useAuditLog (deja conforme) : enregistrement
automatique sur `onAuthSessionCleared` au niveau module, via une
fonction `reset*State()` privee reutilisee par la methode publique
`reset*()` exposee dans le composable.

Respect de la regle CLAUDE.md : "composables avec state singleton
doivent etre reinitialises au logout".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:21:49 +02:00
277178cf19 fix(users) : guard anti-bypass sur sites null dans UserRbacProcessor
restoreAbsentCollections utilisait `array_key_exists('sites', $payload)`
qui retourne true pour une valeur null, sautant la restauration.

API Platform rejette deja `sites: null` au denormalize (400 type mismatch),
donc le bypass n'est pas reellement exploitable aujourd'hui via l'API HTTP.
Mais le test `&& is_array(...)` reste une defense-in-depth si la config
denormalizer change un jour, et rend l'intention explicite.

Test de regression : PATCH {sites: null} -> 400 + collection sites intacte
en DB (aucune trace de l'echec).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:21:49 +02:00
e0624eace0 fix(audit-log) : reset pendingLogs sur onFlush + valide filtres + documente contrat rollback
Trois corrections issues du code review multi-agent sur la PR audit-log :

- AuditListener : reset defensif de pendingLogs en debut de onFlush. Si
  un flush precedent a leve une exception avant postFlush (qui n'est
  jamais appele sur un flush rate), le state listener gardait des
  changements jamais committes, ecrits a tort par le prochain postFlush
  reussi — audit_log pouvait donc contenir des lignes decrivant des
  evenements qui n'ont pas eu lieu en DB. Test de regression via
  Reflection pour injecter un log orphelin et verifier qu'il n'arrive
  pas dans audit_log.

- AuditLogProvider : validation explicite des filtres performed_at[after]
  et performed_at[before] (strtotime) + whitelist stricte sur `action`
  (create|update|delete). Avant, un input malforme remontait jusqu'a
  Postgres et faisait un 500 (SQLSTATE[22007]). Desormais 400 explicite,
  pas de log pollue.

- doc/audit-log.md : ajoute une section "Contrat" qui explicite ce que
  audit_log garantit (journal des intentions appliquees par l'ORM) et ne
  garantit PAS (reflet exact du commit outermost — une ligne audit peut
  persister si une transaction outermost rollback apres un flush inner
  reussi, parce que l'audit ecrit sur une connexion DBAL dediee).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:21:49 +02:00
d3e30e55b2 fix(security) : empeche SeedE2ECommand de tourner hors dev/test + exclusion Docker
Cette commande cree un compte admin (e2e.super-admin, isAdmin=true) avec
un mot de passe hardcode. Le Dockerfile prod copie src/ verbatim, donc le
fichier embarquait un backdoor admin potentiel dans l'image de production.

Defense en profondeur sur deux couches independantes :

- Garde runtime : execute() refuse tout APP_ENV autre que dev|test et
  retourne FAILURE avec message explicite.
- Filet de build : .dockerignore a la racine exclut le fichier du contexte
  de build, donc meme si la garde runtime sautait le fichier ne serait pas
  dans l'image prod.

Injecte egalement SiteProviderInterface (Shared) au lieu de
SiteRepositoryInterface (Module/Sites) en coherence avec le refactor
d'isolation Core/Sites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:21:49 +02:00
18e79a643b refactor(core) : isole Core du module Sites via SiteProviderInterface + resolve_target_entities
Supprime les imports directs de App\Module\Sites\* depuis le module Core :

- SiteProviderInterface (Shared/Contract) : contrat minimal findByName(),
  etendu par SiteRepositoryInterface pour reutilisation DI.
- AppFixtures : injecte SiteProviderInterface au lieu de SiteRepositoryInterface.
- User.php : targetEntity des mappings ORM pointe desormais sur SiteInterface,
  resolu a la classe concrete via doctrine.orm.resolve_target_entities
  (pattern officiel Doctrine pour les bounded contexts DDD).
- JoinColumn/InverseJoinColumn explicites sur la ManyToMany user_site pour
  forcer les noms de colonnes (sinon Doctrine derive site_interface_id).

Respecte la regle CLAUDE.md "jamais d'import direct entre modules" — il
reste l'exception SitesFixtures::class dans getDependencies() (contrainte
d'API DependentFixtureInterface, meme registre que resolve_target_entities).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:21:49 +02:00
19 changed files with 785 additions and 42 deletions

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
src/Module/Core/Infrastructure/Console/SeedE2ECommand.php

View File

@@ -26,6 +26,13 @@ doctrine:
identity_generation_preferences:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true
# Mapping contrat DDD → classe concrete. Permet au module Core de
# referencer `SiteInterface` dans ses ORM mappings (User) sans importer
# la classe concrete du module Sites. Pattern officiel Doctrine pour
# les bounded contexts, remplace l'ancien import direct
# `App\Module\Sites\Domain\Entity\Site` dans User.php.
resolve_target_entities:
App\Shared\Domain\Contract\SiteInterface: App\Module\Sites\Domain\Entity\Site
mappings:
Core:
type: attribute

View File

@@ -28,5 +28,8 @@ services:
App\Module\Sites\Domain\Repository\SiteRepositoryInterface:
alias: App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository
App\Shared\Domain\Contract\SiteProviderInterface:
alias: App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository
App\Module\Sites\Application\Service\CurrentSiteProviderInterface:
alias: App\Module\Sites\Application\Service\CurrentSiteProvider

View 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.

View File

@@ -89,6 +89,19 @@ Table non geree par Doctrine ORM (pas d'entite). Ecriture via DBAL uniquement po
- **`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
@@ -181,7 +194,7 @@ Listener Doctrine (pas EventSubscriber — deprecie Symfony 8) utilisant `#[AsDo
- 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()`.
- 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).
---
@@ -402,7 +415,7 @@ Ticket 1 ────► Ticket 2 ────► Ticket 3 ────┬──
- **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
- **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

View File

@@ -2,14 +2,23 @@
* Composable de lecture des modules actifs (source : `/api/modules`).
*
* State singleton au niveau module : `useSidebar` suit la meme convention.
* Chargement idempotent via le flag `loaded`, reset explicite au logout
* (voir pages/logout.vue).
* Chargement idempotent via le flag `loaded`, reset automatique au logout
* via `onAuthSessionCleared` (cf. CLAUDE.md : « composables avec state
* singleton doivent etre reinitialises au logout »).
*/
import { ref } from 'vue'
import { onAuthSessionCleared } from '~/shared/stores/auth'
const activeModuleIds = ref<string[]>([])
const loaded = ref(false)
function resetModulesState(): void {
activeModuleIds.value = []
loaded.value = false
}
onAuthSessionCleared(resetModulesState)
export function useModules() {
async function loadModules() {
try {
@@ -35,8 +44,7 @@ export function useModules() {
}
function resetModules() {
activeModuleIds.value = []
loaded.value = false
resetModulesState()
}
return {

View File

@@ -1,10 +1,23 @@
import { ref } from 'vue'
import type { SidebarSection } from '~/shared/types'
import { onAuthSessionCleared } from '~/shared/stores/auth'
const sections = ref<SidebarSection[]>([])
const disabledRoutes = ref<string[]>([])
const loaded = ref(false)
function resetSidebarState(): void {
sections.value = []
disabledRoutes.value = []
loaded.value = false
}
// Auto-enregistrement singleton : purge la sidebar sur 401/logout pour
// eviter qu'un nouvel utilisateur logue sur le meme onglet voie transitoirement
// les items de l'ancienne session (cf. CLAUDE.md : « composables avec state
// singleton doivent etre reinitialises au logout »).
onAuthSessionCleared(resetSidebarState)
export function useSidebar() {
async function loadSidebar() {
try {
@@ -31,9 +44,7 @@ export function useSidebar() {
}
function resetSidebar() {
sections.value = []
disabledRoutes.value = []
loaded.value = false
resetSidebarState()
}
return {

View File

@@ -18,11 +18,11 @@
<server name="KERNEL_CLASS" value="App\Kernel" />
<!-- ###+ symfony/framework-bundle ### -->
<!-- APP_ENV est force a "test" en <server> ci-dessus : on ne doit PAS
re-injecter "dev" ici via <env>, sinon la suite tourne sous
framework.test=false et `test.service_container` n'est pas cable
(cf. cc8d5 du fix pre-existant). -->
<env name="APP_ENV" value="test"/>
<!-- APP_ENV est defini uniquement via <server force="true"> ci-dessus.
Ne PAS re-declarer ici en <env> : une ligne redondante mene
directement au bug ou un dev met "dev" en pensant que <server>
gere tout, puis supprime <server> ensuite et <env> prend le
dessus silencieusement (cf. cc8d5 du fix pre-existant). -->
<env name="APP_SECRET" value=""/>
<env name="APP_SHARE_DIR" value="var/share"/>
<!-- ###- symfony/framework-bundle ### -->

View File

@@ -15,12 +15,11 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
// Note architecture : User.php utilise SiteInterface (Shared) pour les
// type-hints afin de ne pas coupler le module Core au module Sites.
// La seule reference concrete a Site subsiste dans les metadonnees ORM
// (targetEntity) via FQCN string, ce qui est obligatoire pour Doctrine.
// SiteNotAuthorizedException est importee depuis Shared (sa location canonique).
use App\Module\Sites\Domain\Entity\Site;
// Note architecture : User.php n'importe plus rien depuis le module Sites.
// Les type-hints utilisent SiteInterface (Shared/Contract) et le mapping ORM
// pointe vers la meme interface, resolue vers la classe concrete Site au boot
// via `doctrine.orm.resolve_target_entities` (cf. config/packages/doctrine.yaml).
// C'est le pattern officiel Doctrine pour les bounded contexts DDD.
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
use App\Shared\Domain\Contract\SiteInterface;
@@ -139,10 +138,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* Le groupe `user:list` a ete retire deliberement (securite : evite
* de fuiter la liste des sites de chaque user via GET /api/users).
*
* @var Collection<int, Site>
* @var Collection<int, SiteInterface>
*/
#[ORM\ManyToMany(targetEntity: 'App\Module\Sites\Domain\Entity\Site', inversedBy: 'users', fetch: 'LAZY')]
#[ORM\ManyToMany(targetEntity: SiteInterface::class, inversedBy: 'users', fetch: 'LAZY')]
#[ORM\JoinTable(name: 'user_site')]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[Groups(['me:read', 'user:rbac:read', 'user:rbac:write'])]
private Collection $sites;
@@ -162,7 +163,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* le prechargement pour /api/me. Le groupe `user:list` a ete retire
* deliberement (securite : evite de fuiter le site actif via /api/users).
*/
#[ORM\ManyToOne(targetEntity: 'App\Module\Sites\Domain\Entity\Site', fetch: 'LAZY')]
#[ORM\ManyToOne(targetEntity: SiteInterface::class, fetch: 'LAZY')]
#[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['me:read'])]
private ?SiteInterface $currentSite = null;
@@ -378,7 +379,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
}
/**
* @return Collection<int, Site>
* @return Collection<int, SiteInterface>
*/
public function getSites(): Collection
{
@@ -392,7 +393,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* session Doctrine (cf. ticket 2 review point #1).
*
* Le parametre est type SiteInterface pour eviter le couplage Core → Sites.
* En pratique seule App\Module\Sites\Domain\Entity\Site est passee ici.
* La classe concrete injectee au runtime est resolue par Doctrine via
* `resolve_target_entities` (cf. note architecture en tete de fichier).
*/
public function addSite(SiteInterface $site): static
{

View File

@@ -250,7 +250,12 @@ final class UserRbacProcessor implements ProcessorInterface
}
foreach (self::COLLECTION_MAP as $jsonKey => $accessors) {
if (array_key_exists($jsonKey, $payload)) {
// La garde ne doit sauter la restauration que si le payload fournit
// un VRAI tableau pour cette cle. Un `null`, un scalaire ou un autre
// type doivent etre traites comme "cle absente" : sinon un payload
// `{"sites": null}` contourne la restauration et laisse API Platform
// vider la collection silencieusement (bypass de la garde).
if (array_key_exists($jsonKey, $payload) && is_array($payload[$jsonKey])) {
continue;
}

View File

@@ -15,6 +15,7 @@ use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Provider API Platform pour la resource AuditLog.
@@ -67,7 +68,11 @@ final readonly class AuditLogProvider implements ProviderInterface
*/
private function provideCollection(Operation $operation, array $context): DbalPaginator
{
$page = $this->pagination->getPage($context);
// `page` brut peut etre <= 0 (parametre client) → OFFSET negatif → 500 PG
// (`SQLSTATE[22023] OFFSET must not be negative`). API Platform clampe
// `itemsPerPage` au max de la resource mais pas `page` ; on impose un
// minimum a 1 cote provider.
$page = max(1, $this->pagination->getPage($context));
$itemsPerPage = $this->pagination->getLimit($operation, $context);
$offset = ($page - 1) * $itemsPerPage;
$filters = $this->extractFilters($context['filters'] ?? []);
@@ -128,20 +133,42 @@ final readonly class AuditLogProvider implements ProviderInterface
}
}
foreach (['entity_id', 'action', 'performed_by'] as $key) {
foreach (['entity_id', 'performed_by'] as $key) {
if (isset($raw[$key]) && is_string($raw[$key]) && '' !== $raw[$key]) {
$filters[$key] = $raw[$key];
}
}
// `action` : whitelist stricte. Un input hors-liste provoquait avant
// un simple match vide (resultat 0 ligne) mais permettait d'incrementer
// le log applicatif a chaque variation ; on rejette en 400 explicite.
if (isset($raw['action']) && is_string($raw['action']) && '' !== $raw['action']) {
if (!in_array($raw['action'], ['create', 'update', 'delete'], true)) {
throw new BadRequestHttpException(
'Filtre "action" invalide : valeurs autorisees create|update|delete.',
);
}
$filters['action'] = $raw['action'];
}
// Filtres de plage `performed_at[after]` / `performed_at[before]`.
// Sans validation, un input malforme remonte jusqu'a Postgres qui
// leve `SQLSTATE[22007]: invalid input syntax for type timestamp` →
// 500 Internal Server Error, log Monolog pollue, mauvaise UX API.
// On valide en amont et on rejette en 400 explicite.
if (isset($raw['performed_at']) && is_array($raw['performed_at'])) {
$range = $raw['performed_at'];
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'];
foreach (['after', 'before'] as $bound) {
if (!isset($range[$bound]) || !is_string($range[$bound]) || '' === $range[$bound]) {
continue;
}
if (false === strtotime($range[$bound])) {
throw new BadRequestHttpException(sprintf(
'Filtre "performed_at[%s]" invalide : date ISO 8601 attendue (ex: 2026-04-22T00:00:00Z).',
$bound,
));
}
$filters['performed_at_'.$bound] = $range[$bound];
}
}

View File

@@ -9,7 +9,7 @@ use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles;
use App\Module\Sites\Domain\Repository\SiteRepositoryInterface;
use App\Shared\Domain\Contract\SiteProviderInterface;
use Doctrine\ORM\EntityManagerInterface;
use RuntimeException;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -50,7 +50,7 @@ final class SeedE2ECommand extends Command
private readonly UserRepositoryInterface $userRepository,
private readonly RoleRepositoryInterface $roleRepository,
private readonly PermissionRepositoryInterface $permissionRepository,
private readonly SiteRepositoryInterface $siteRepository,
private readonly SiteProviderInterface $siteProvider,
private readonly UserPasswordHasherInterface $passwordHasher,
) {
parent::__construct();
@@ -60,6 +60,17 @@ final class SeedE2ECommand extends Command
{
$io = new SymfonyStyle($input, $output);
// Garde-fou : cette commande cree un compte admin avec un mot de passe
// hardcode. Elle ne doit JAMAIS tourner hors dev/test, meme si le
// fichier se retrouve embarque dans une image prod par accident (le
// .dockerignore a la racine est la premiere ligne de defense).
$env = $_SERVER['APP_ENV'] ?? 'prod';
if (!in_array($env, ['dev', 'test'], true)) {
$io->error(sprintf('app:seed-e2e est refuse en environnement "%s". Autorise uniquement en dev/test.', $env));
return Command::FAILURE;
}
$userRole = $this->roleRepository->findByCode(SystemRoles::USER_CODE);
if (null === $userRole) {
@@ -71,7 +82,7 @@ final class SeedE2ECommand extends Command
return Command::FAILURE;
}
$defaultSite = $this->siteRepository->findByName(self::DEFAULT_SITE_NAME);
$defaultSite = $this->siteProvider->findByName(self::DEFAULT_SITE_NAME);
// Pas de fail fatal si le site manque : les tests sidebar/login
// n'en dependent pas. Les tests sites-scope-bypass (a venir) le feront.

View File

@@ -8,9 +8,9 @@ use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Sites\Domain\Repository\SiteRepositoryInterface;
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SiteProviderInterface;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
@@ -39,7 +39,7 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly RoleRepositoryInterface $roleRepository,
private readonly SiteRepositoryInterface $siteRepository,
private readonly SiteProviderInterface $siteProvider,
) {}
/**
@@ -135,9 +135,9 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
return $role;
}
private function requireSite(string $name): Site
private function requireSite(string $name): SiteInterface
{
$site = $this->siteRepository->findByName($name);
$site = $this->siteProvider->findByName($name);
if (null === $site) {
throw new RuntimeException(sprintf(

View File

@@ -102,6 +102,14 @@ final class AuditListener
$em = $args->getObjectManager();
$uow = $em->getUnitOfWork();
// Reset defensif en debut de cycle : si un flush precedent a leve une
// exception, Doctrine n'appelle PAS postFlush et pendingLogs reste
// rempli avec des changements jamais committes. Sans ce reset, un
// flush ulterieur reussi ecrirait les fausses entrees dans audit_log.
// Le swap-and-clear dans postFlush couvre deja les flushes re-entrants,
// ce reset ne le fragilise donc pas.
$this->pendingLogs = [];
foreach ($uow->getScheduledEntityInsertions() as $entity) {
$this->capturePendingLog($entity, $em, $uow, 'create');
}

View File

@@ -5,8 +5,9 @@ declare(strict_types=1);
namespace App\Module\Sites\Domain\Repository;
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Contract\SiteProviderInterface;
interface SiteRepositoryInterface
interface SiteRepositoryInterface extends SiteProviderInterface
{
public function findById(int $id): ?Site;

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat minimal pour acceder a un site depuis un module qui n'est pas Sites.
*
* Permet a du code Core/Shared (commandes de seed, fixtures, etc.) de
* recuperer un Site par son nom sans importer directement depuis le module
* Sites — ce qui violerait la regle "jamais d'import direct entre modules"
* (cf. CLAUDE.md section "Regles d'architecture").
*
* Implementation concrete : App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository
* (via SiteRepositoryInterface qui etend ce contrat).
*/
interface SiteProviderInterface
{
public function findByName(string $name): ?SiteInterface;
}

View File

@@ -188,6 +188,69 @@ final class AuditLogApiTest extends AbstractApiTestCase
self::assertSame($row['id'], $data['id']);
}
/**
* Symetrique de `testAuthenticatedUserWithoutPermissionGets403` mais sur
* l'endpoint item. Le `security: is_granted('core.audit_log.view')` declare
* sur `Get` dans `AuditLogResource` doit refuser 403 (pas 200, pas 404).
*/
public function testItemEndpointWithoutPermissionGets403(): void
{
$row = $this->auditConnection->fetchAssociative(
'SELECT id FROM audit_log WHERE request_id = :tag LIMIT 1',
['tag' => $this->runTag],
);
self::assertIsArray($row);
// Permission "voisine" : prouve que l'auth seule ne suffit pas.
$credentials = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$response = $client->request('GET', '/api/audit-logs/'.$row['id']);
self::assertSame(403, $response->getStatusCode());
}
/**
* `?page=0` provoquait historiquement un OFFSET negatif → 500 PG
* `SQLSTATE[22023] OFFSET must not be negative`. API Platform 4 valide
* desormais `page >= 1` en amont (rejette 400) avant que le provider ne
* soit appele ; le clamp `max(1, ...)` cote provider reste en place comme
* defense-in-depth si un futur upgrade ou un changement de configuration
* leve cette validation. Ce test verrouille l'invariant fonctionnel :
* aucun 500 PG quel que soit le mecanisme protecteur en place.
*/
public function testPageZeroDoesNotProduceServerError(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/audit-logs?page=0');
self::assertContains(
$response->getStatusCode(),
[200, 400],
'page=0 doit etre traite proprement (clamp 200 ou 400 explicite), jamais 500 PG.',
);
}
/**
* Validation des filtres : un input malforme doit retourner un 400
* explicite, pas un 500 (Postgres qui rejette le cast timestamp) ni
* un match silencieux sur une valeur inattendue.
*/
public function testInvalidPerformedAtFilterReturns400(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/audit-logs?performed_at[after]=pas-une-date');
self::assertSame(400, $response->getStatusCode());
}
public function testInvalidActionFilterReturns400(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/audit-logs?action=dropTable');
self::assertSame(400, $response->getStatusCode());
}
public function testPostIsNotAllowed(): void
{
$client = $this->authenticatedClient('admin', 'admin');

View File

@@ -139,6 +139,46 @@ final class UserRbacSitesApiTest extends AbstractApiTestCase
self::assertSame('Chatellerault', $reloaded->getCurrentSite()->getName());
}
/**
* Defense-in-depth contre un bypass hypothetique de restoreAbsentCollections.
*
* API Platform rejette deja `sites: null` au denormalize (400 Bad Request,
* type mismatch). Le guard `is_array` dans UserRbacProcessor est une
* deuxieme ligne de defense si la config denormalizer change un jour.
*
* Ce test verrouille deux garanties :
* 1. `{"sites": null}` → 400 (pas un 500, pas un 200 silencieux)
* 2. La collection sites d'alice est intacte apres l'echec
*/
public function testRbacPatchWithNullSitesReturns400AndDoesNotWipeSitesCollection(): void
{
$em = $this->getEm();
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$aliceId = $alice->getId();
self::assertCount(1, $alice->getSites(), 'Pre-condition : alice doit avoir exactement 1 site');
$em->clear();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [
'isAdmin' => false,
'sites' => null,
],
]);
self::assertResponseStatusCodeSame(400);
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(User::class)->find($aliceId);
self::assertCount(
1,
$reloaded->getSites(),
'Un payload `sites: null` rejete en 400 ne doit laisser aucune trace en DB.',
);
}
public function testRbacPatchWithoutSitesFieldDoesNotChangeCurrentSite(): void
{
// Garde structurelle : si le payload /rbac ne contient pas le champ

View File

@@ -7,8 +7,11 @@ 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 App\Module\Core\Infrastructure\Doctrine\AuditListener;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use ReflectionProperty;
use stdClass;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
@@ -328,6 +331,59 @@ final class AuditListenerTest extends KernelTestCase
);
}
/**
* Regression : quand un flush precedent a leve une exception avant
* d'atteindre postFlush, `$pendingLogs` reste rempli avec des changements
* jamais committes. Le flush suivant doit les ecraser, pas les fusionner —
* sinon audit_log contient des lignes pour des evenements qui n'ont pas
* eu lieu en base.
*
* Reproduction : on injecte manuellement une entree orpheline dans le
* listener (comme si un flush precedent l'avait capturee puis avait plante),
* on declenche un flush valide, et on verifie que l'orpheline n'apparait
* jamais dans audit_log.
*/
public function testOnFlushResetsStalePendingLogsFromFailedPreviousFlush(): void
{
/** @var AuditListener $listener */
$listener = self::getContainer()->get(AuditListener::class);
// Injecte une entree orpheline : comme si onFlush avait capture ce
// changement, puis que le flush avait plante avant postFlush.
$reflection = new ReflectionProperty($listener, 'pendingLogs');
$reflection->setValue($listener, [[
'entity' => new stdClass(),
'metadata' => null,
'entityType' => 'test.StaleEntity',
'action' => 'create',
'changes' => ['fake' => ['old' => null, 'new' => 'stale']],
'capturedId' => 'stale-id-'.$this->testRunTag,
]]);
// Flush valide qui DOIT re-initialiser pendingLogs avant de capturer
// ses propres changements.
$user = $this->makeUser();
$this->em->persist($user);
$this->em->flush();
$this->createdUserIds[] = $user->getId();
$staleRows = $this->auditConnection->fetchAllAssociative(
'SELECT * FROM audit_log WHERE entity_type = :t',
['t' => 'test.StaleEntity'],
);
self::assertCount(
0,
$staleRows,
'Une entree orpheline d\'un flush precedent ne doit pas fuiter dans audit_log.',
);
// Sanity : le vrai flush a quand meme bien ecrit sa propre ligne.
$userRows = $this->fetchAuditRows($user->getId());
self::assertCount(1, $userRows);
self::assertSame('create', $userRows[0]['action']);
}
/**
* @return list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string}>
*/