Files
Coltura/TICKETS.md
matthieu e6c8381b3c
Some checks failed
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Failing after 9s
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: #9
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Co-committed-by: matthieu <matthieu@yuno.malio.fr>
2026-05-13 08:29:30 +00:00

1027 lines
37 KiB
Markdown

# Tickets correctifs — PR `feat/audit-log` (4e passe)
> Issus de la review du 2026-04-23 (`REVIEW.md`).
> Chaque ticket est autonome : pourquoi, quoi faire, fichiers concernes.
> Commence par les P0. Un commit par ticket (message `fix(T-XXX) : description`).
---
## P0 — Urgents (securite avant mise en prod)
### T-001 — Fermer `/api/docs` en production
**Pourquoi :** la doc Swagger expose aujourd'hui publiquement la liste de tous les endpoints, les permissions RBAC demandees, les filtres disponibles, les DTOs. Pour un outil interne c'est inutile et ca donne a un attaquant la carte complete de la surface d'attaque sans meme avoir a se connecter.
**A faire :**
1. Ouvrir `config/packages/security.yaml`.
2. Supprimer la ligne qui ouvre `/api/docs` :
```yaml
# AVANT
access_control:
- { path: ^/login_check, roles: PUBLIC_ACCESS }
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/api/modules, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/api/sidebar, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
# APRES (on supprime simplement la 2e ligne)
access_control:
- { path: ^/login_check, roles: PUBLIC_ACCESS }
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/api/modules, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/api/sidebar, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
```
Comme `/api/docs` tombe desormais dans le dernier pattern (`^/api`), il faudra etre authentifie pour le voir. Les devs continueront de l'utiliser apres login — les attaquants non.
3. Recharger : `make cache-clear` puis `make restart`.
4. Tester : `curl -i https://coltura.malio-dev.fr/api/docs` doit retourner `401 Unauthorized` (avant : `200`).
**Fichiers :** `config/packages/security.yaml`
---
### T-002 — Ajouter les en-tetes de securite HTTP de base en prod
**Pourquoi :** sans `X-Frame-Options`, quelqu'un peut integrer Coltura dans une iframe sur un site tiers et faire du clickjacking (faire croire a l'utilisateur qu'il clique sur un bouton anodin alors qu'il valide une action dans Coltura). Sans `X-Content-Type-Options: nosniff`, un navigateur peut deviner le type MIME et executer un fichier qui n'aurait pas du l'etre. Ce sont 3 lignes de config Nginx pour proteger l'application.
**A faire :**
1. Ouvrir `infra/prod/nginx-proxy.conf` (c'est le proxy expose au public).
2. Ajouter juste apres `server_name coltura.malio-dev.fr;` :
```nginx
# En-tetes de securite applicables a toutes les reponses
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
```
Explication :
- `X-Frame-Options: DENY` : personne ne peut mettre Coltura dans une iframe.
- `X-Content-Type-Options: nosniff` : le navigateur ne devine pas les types MIME, il fait confiance a ce que le serveur annonce.
- `Referrer-Policy: strict-origin-when-cross-origin` : limite ce que Coltura envoie comme Referer a des sites externes (evite de leaker `/admin/users/42` a un site tiers).
- `always` : envoyer ces en-tetes meme sur les reponses d'erreur (4xx/5xx).
3. Recharger Nginx : `docker restart nginx-coltura` (ou celui qui fait office de proxy public).
4. Verifier : `curl -I https://coltura.malio-dev.fr/` doit afficher ces trois en-tetes.
**Note :** si un reverse proxy externe (Traefik, Cloudflare) ajoute deja ces en-tetes, les poser ici ne fait que dupliquer, c'est sans risque (meme valeur).
**Fichiers :** `infra/prod/nginx-proxy.conf`
---
### T-003 — Interdire l'indexation par les moteurs de recherche
**Pourquoi :** `frontend/public/robots.txt` autorise actuellement Google et compagnie a indexer toutes les pages. Pour un CRM interne, pas besoin que les URLs de pages admin, de fiches clients, etc. remontent dans les resultats de recherche. Meme si le contenu est protege par login, la simple enumeration des URLs publiques est un leak.
**A faire :**
1. Ouvrir `frontend/public/robots.txt`.
2. Remplacer le contenu par :
```
User-Agent: *
Disallow: /
```
**Fichiers :** `frontend/public/robots.txt`
---
## P1 — Importants (bugs silencieux + qualite)
### T-004 — Valider/typer le filtre `performed_at` sur le journal d'audit
**Pourquoi :** quand un utilisateur envoie `?performed_at[after]=pasunedate`, PostgreSQL n'arrive pas a caster la chaine en timestamp et leve une erreur. L'API retourne un 500 (erreur serveur) au lieu du 400 propre (erreur client) qu'elle devrait. En plus, les logs d'erreur se remplissent inutilement.
**A faire :**
1. Ouvrir `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`.
2. En haut du fichier, ajouter les imports suivants si absents :
```php
use Doctrine\DBAL\Types\Types;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
```
3. Dans `applyFilters()`, remplacer les blocs `performed_at_after` et `performed_at_before` :
```php
// AVANT
if (isset($filters['performed_at_after'])) {
$qb->andWhere('performed_at >= :performed_at_after')
->setParameter('performed_at_after', $filters['performed_at_after']);
}
if (isset($filters['performed_at_before'])) {
$qb->andWhere('performed_at <= :performed_at_before')
->setParameter('performed_at_before', $filters['performed_at_before']);
}
```
```php
// APRES
if (isset($filters['performed_at_after'])) {
try {
$after = new \DateTimeImmutable($filters['performed_at_after']);
} catch (\Throwable) {
throw new BadRequestHttpException('performed_at[after] doit etre une date ISO 8601 valide.');
}
$qb->andWhere('performed_at >= :performed_at_after')
->setParameter('performed_at_after', $after, Types::DATETIMETZ_IMMUTABLE);
}
if (isset($filters['performed_at_before'])) {
try {
$before = new \DateTimeImmutable($filters['performed_at_before']);
} catch (\Throwable) {
throw new BadRequestHttpException('performed_at[before] doit etre une date ISO 8601 valide.');
}
$qb->andWhere('performed_at <= :performed_at_before')
->setParameter('performed_at_before', $before, Types::DATETIMETZ_IMMUTABLE);
}
```
4. Tester : `curl -i -b cookie.txt 'http://localhost:8083/api/audit-logs?performed_at%5Bafter%5D=pasunedate'` doit retourner `400 Bad Request`, plus `500`.
5. Tester aussi avec une date valide : `curl ... 'performed_at%5Bafter%5D=2026-01-01T00:00:00Z'` doit retourner `200` avec les bons resultats.
**Fichiers :** `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`
---
### T-005 — Ajouter la clause `ESCAPE` sur le filtre ILIKE `performed_by`
**Pourquoi :** le filtre "recherche contient" sur le nom d'utilisateur utilise `ILIKE`. Quand un user cherche `admin_backup`, le `_` est un caractere special LIKE qui matche "n'importe quel caractere". Le code essaie d'echapper avec `\_` mais **il faut dire a PostgreSQL que `\` est l'echappement** via la clause `ESCAPE`. Sans ca, sur certaines configs PG, `\_` devient deux caracteres distincts et l'echappement est cassé.
Concretement : chercher `admin_backup` peut faire remonter aussi `adminXbackup`, `admin1backup`, etc.
**A faire :**
1. Ouvrir `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`.
2. Dans `applyFilters()`, modifier le bloc `performed_by` :
```php
// AVANT (lignes ~171-180)
if (isset($filters['performed_by'])) {
// Recherche contains insensible a la casse pour matcher "adm" → "admin".
// On echappe `%`, `_` et `\` saisis par l'utilisateur pour qu'ils soient
// interpretes comme caracteres litteraux (sinon `%` matche tout, `_`
// matche n'importe quel caractere). Pas de clause ESCAPE : `\` est
// deja le caractere d'echappement LIKE par defaut en PostgreSQL.
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $filters['performed_by']);
$qb->andWhere('performed_by ILIKE :performed_by')
->setParameter('performed_by', '%'.$escaped.'%')
;
}
```
```php
// APRES
if (isset($filters['performed_by'])) {
// Recherche contains insensible a la casse pour matcher "adm" → "admin".
// On echappe `%`, `_` et `\` pour qu'ils soient traites comme caracteres
// litteraux. La clause ESCAPE est EXPLICITE : en SQL standard, LIKE n'a
// pas d'echappement par defaut, il faut le declarer.
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $filters['performed_by']);
$qb->andWhere("performed_by ILIKE :performed_by ESCAPE '\\\\'")
->setParameter('performed_by', '%'.$escaped.'%')
;
}
```
Note : les 4 `\` en PHP donnent 2 `\` dans la string SQL, qui devient 1 `\` apres parsing PostgreSQL. C'est la chaine d'echappement correcte.
3. Tester en psql : creer une entree avec `performed_by = 'admin_backup'`, puis :
```bash
curl -b cookie.txt 'http://localhost:8083/api/audit-logs?performed_by=admin_backup'
# → doit retourner UNIQUEMENT admin_backup, pas adminXbackup ou autres
```
**Fichiers :** `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`
---
### T-006 — Refuser explicitement les appelants non-`User` dans `SiteAwareInjectionProcessor`
**Pourquoi :** le processor qui injecte/valide le site lors de la creation d'une entite site-aware verifie `$user instanceof User`. Si jamais l'appelant n'est pas exactement une instance de cette classe (ex: futur provider d'auth, session systeme), la condition est fausse et **la validation est silencieusement sautee** — l'utilisateur pourrait creer une entite dans un site auquel il n'appartient pas. Aujourd'hui le risque est nul car il n'y a qu'un seul provider, mais le pattern "pas le bon type = on laisse passer" est fragile.
**A faire :**
1. Ouvrir `src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessor.php`.
2. Modifier la partie validation cross-site (ligne 64-75) :
```php
// AVANT
if (!$this->security->isGranted('sites.bypass_scope')) {
$user = $this->security->getUser();
$explicitSite = $data->getSite();
if ($user instanceof User && $explicitSite instanceof Site && !$user->hasSite($explicitSite)) {
throw new AccessDeniedHttpException(
'Le site specifie n\'est pas dans les sites autorises pour cet utilisateur.'
);
}
}
```
```php
// APRES
if (!$this->security->isGranted('sites.bypass_scope')) {
$user = $this->security->getUser();
// Pas de User authentifie = rejet explicite. On ne laisse pas passer
// une ecriture site-scoped si on ne peut pas valider le proprietaire.
if (!$user instanceof User) {
throw new AccessDeniedHttpException(
'Utilisateur non reconnu pour la validation de site.',
);
}
$explicitSite = $data->getSite();
if ($explicitSite instanceof Site && !$user->hasSite($explicitSite)) {
throw new AccessDeniedHttpException(
'Le site specifie n\'est pas dans les sites autorises pour cet utilisateur.',
);
}
}
```
3. Relancer la suite back : `make test`. Les tests existants sur `SiteAwareInjectionProcessor` doivent encore passer (l'acceptation du happy path est inchangee).
**Fichiers :** `src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessor.php`
---
### T-007 — Entourer `isHandlingUnauthorized` d'un `try/finally`
**Pourquoi :** dans `useApi`, un flag singleton bloque les appels concurrents a `navigateTo('/login')` quand une 401 survient. Si `navigateTo` echoue (middleware qui throw, plugin qui throw, etc.), le flag reste sur `true` indefiniment et **toutes les 401 suivantes sont ignorees silencieusement**. L'utilisateur doit hard-reload pour s'en sortir.
**A faire :**
1. Ouvrir `frontend/shared/composables/useApi.ts`.
2. Dans le handler `onResponseError`, modifier le bloc 401 (lignes 124-131) :
```typescript
// AVANT
if (!isLoginCheck && !isLogout) {
if (!isHandlingUnauthorized) {
isHandlingUnauthorized = true
auth.clearSession()
await navigateTo('/login')
isHandlingUnauthorized = false
}
}
```
```typescript
// APRES
if (!isLoginCheck && !isLogout) {
if (!isHandlingUnauthorized) {
isHandlingUnauthorized = true
try {
auth.clearSession()
await navigateTo('/login')
} finally {
// Garantir la remise a false meme si navigateTo throw : sinon
// les 401 suivantes seraient silencieusement ignorees jusqu'a
// un hard reload.
isHandlingUnauthorized = false
}
}
}
```
3. Pas de test automatise trivial a ecrire (depend de navigateTo) — test manuel : se connecter, laisser le token JWT expirer (ou le supprimer manuellement dans le cookie), declencher deux appels quasi-simultanes et verifier la redirection `/login`.
**Fichiers :** `frontend/shared/composables/useApi.ts`
---
### T-008 — Capper la pagination sur `Permission`, `Role`, `Site` + nettoyer `itemsPerPage: 999`
**Pourquoi :** plusieurs composants (drawers RBAC, page admin sites) appellent l'API avec `itemsPerPage: 999`. Or, `paginationClientItemsPerPage` n'est pas active sur ces ressources, donc le 999 **est silencieusement ignore** et on recoit toujours 30 elements. Aujourd'hui les catalogues sont petits, ca marche par chance. Demain si on passe a 35 permissions, les drawers tronqueront silencieusement — bug invisible.
Parallelement, si on active la pagination client sans plafond, `?itemsPerPage=99999` devient une requete qui sort tout en une fois — DoS bas effort.
La solution la plus simple : desactiver la pagination pour ces catalogues (ils sont exhaustifs par nature) et retirer les `itemsPerPage: 999` cote front.
**A faire :**
1. **Backend** — Ajouter `paginationEnabled: false` sur les `GetCollection` de ces trois ressources.
`src/Module/Core/Domain/Entity/Permission.php` (vers ligne 28-31) :
```php
// APRES
new GetCollection(
normalizationContext: ['groups' => ['permission:read']],
security: "is_granted('core.permissions.view') or is_granted('core.users.manage') or is_granted('core.roles.manage')",
paginationEnabled: false,
),
```
`src/Module/Core/Domain/Entity/Role.php` (trouver le `new GetCollection` et ajouter la meme ligne).
`src/Module/Sites/Domain/Entity/Site.php` (meme chose sur le `GetCollection`) :
```php
new GetCollection(
normalizationContext: ['groups' => ['site:read']],
security: "is_granted('sites.view')",
paginationEnabled: false,
),
```
**Attention** : pour `Site`, la page admin va recuperer TOUS les sites. Tant qu'on reste sous quelques dizaines c'est OK. Si la taille explose plus tard, remettre une pagination avec un plafond explicite (`paginationClientItemsPerPage: true`, `paginationMaximumItemsPerPage: 200`).
2. **Frontend** — Retirer les `itemsPerPage: 999`.
`frontend/modules/core/components/UserRbacDrawer.vue:235-236` :
```typescript
// AVANT
api.get<{ member: Permission[] }>('/permissions', { orphan: false, itemsPerPage: 999 }, { toast: true }),
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: true }),
// APRES
api.get<{ member: Permission[] }>('/permissions', { orphan: false }, { toast: true }),
api.get<{ member: Site[] }>('/sites', {}, { toast: true }),
```
`frontend/modules/core/components/RoleDrawer.vue:149` :
```typescript
// AVANT
const data = await api.get<{ member: Permission[] }>(
'/permissions',
{ 'orphan': false, itemsPerPage: 999 },
{ toast: true },
)
// APRES
const data = await api.get<{ member: Permission[] }>(
'/permissions',
{ orphan: false },
{ toast: true },
)
```
`frontend/modules/sites/pages/admin/sites.vue:117` :
```typescript
// AVANT
const data = await api.get<{ member: Site[] }>(
'/sites',
{ itemsPerPage: 999 },
{ toast: false },
)
// APRES
const data = await api.get<{ member: Site[] }>(
'/sites',
{},
{ toast: false },
)
```
3. Tester : ouvrir le `UserRbacDrawer`, verifier que toutes les permissions et tous les sites s'affichent. Idem sur `RoleDrawer` et la page `admin/sites`.
4. Relancer `make test` (PHP) et `make nuxt-test` (front) pour verifier la non-regression.
**Fichiers :**
- `src/Module/Core/Domain/Entity/Permission.php`
- `src/Module/Core/Domain/Entity/Role.php`
- `src/Module/Sites/Domain/Entity/Site.php`
- `frontend/modules/core/components/UserRbacDrawer.vue`
- `frontend/modules/core/components/RoleDrawer.vue`
- `frontend/modules/sites/pages/admin/sites.vue`
---
### T-009 — Proteger `JSON.stringify` dans `AuditLogDetail.vue`
**Pourquoi :** la fonction `formatValue` serialise des valeurs venant de l'API avec `JSON.stringify`. Si un jour la valeur contient un objet avec une reference circulaire (ex: refacto ORM qui serialise une entite), `stringify` leve une erreur qui casse tout le rendu du drawer. Ajouter un try/catch defensif evite de devoir debugger ca en prod.
**A faire :**
1. Ouvrir `frontend/shared/components/audit/AuditLogDetail.vue`.
2. Reperer la fonction `formatValue` (ou equivalent qui appelle `JSON.stringify`).
3. L'entourer d'un try/catch :
```typescript
// AVANT (approximativement)
function formatValue(value: unknown): string {
if (value === null || value === undefined) return '-'
if (typeof value === 'boolean') return value ? t('common.yes') : t('common.no')
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
```
```typescript
// APRES
function formatValue(value: unknown): string {
if (value === null || value === undefined) return '-'
if (typeof value === 'boolean') return value ? t('common.yes') : t('common.no')
if (typeof value === 'object') {
try {
return JSON.stringify(value)
} catch {
return '[valeur non serialisable]'
}
}
return String(value)
}
```
**Fichiers :** `frontend/shared/components/audit/AuditLogDetail.vue`
---
## P2 — Qualite et conventions
### T-010 — Remplacer `<button>` brut par `MalioButton` dans `AuditTimeline`
**Pourquoi :** la regle projet (`.claude/rules/frontend.md`) impose `MalioButton` pour tous les boutons. Un seul bouton HTML brut subsiste dans la PR — celui de "Voir plus" dans la timeline.
**A faire :**
1. Ouvrir `frontend/shared/components/audit/AuditTimeline.vue`, vers ligne 80.
2. Remplacer :
```html
<!-- AVANT -->
<button
type="button"
class="mt-3 text-sm text-blue-600 hover:text-blue-800"
@click="loadMore"
>
{{ t('audit.timeline.load_more') }}
</button>
```
```html
<!-- APRES -->
<MalioButton
type="secondary"
size="sm"
:label="t('audit.timeline.load_more')"
class="mt-3"
@click="loadMore"
/>
```
Si `MalioButton` n'a pas de variant adapte au rendu "lien inline" actuel, garder le `<button>` mais ajouter un TODO explicite :
```html
<!-- TODO(malio-ui) : pas de variant 'link-inline' dispo dans @malio/layer-ui 1.4.2 -->
<button type="button" ...>
```
3. Lancer `make dev-nuxt` et verifier visuellement que le bouton s'affiche et fonctionne.
**Fichiers :** `frontend/shared/components/audit/AuditTimeline.vue`
---
### T-011 — Deplacer `sidebar.core.sites` sous `sidebar.sites.*`
**Pourquoi :** la convention `.claude/rules/naming.md` impose les cles i18n sidebar sous le namespace du module owner (`sidebar.<module>.*`). L'item "Sites" a `'module' => 'sites'` dans `sidebar.php` mais sa cle est `sidebar.core.sites` — incoherent, et si on desactive le module `sites`, il faudra chasser cette cle sous `core`.
**A faire :**
1. Ouvrir `frontend/i18n/locales/fr.json`, trouver la section `sidebar` :
```json
// AVANT
"sidebar": {
"core": {
"roles": "Gestion des rôles",
"users": "Utilisateurs",
"sites": "Sites",
"audit_log": "Journal d'audit"
}
}
```
```json
// APRES
"sidebar": {
"core": {
"roles": "Gestion des rôles",
"users": "Utilisateurs",
"audit_log": "Journal d'audit"
},
"sites": {
"admin": "Sites"
}
}
```
2. Ouvrir `config/sidebar.php`, mettre a jour le label de l'item Sites :
```php
// AVANT
[
'label' => 'sidebar.core.sites',
'to' => '/admin/sites',
'icon' => 'mdi:domain',
'module' => 'sites',
'permission' => 'sites.view',
],
```
```php
// APRES
[
'label' => 'sidebar.sites.admin',
'to' => '/admin/sites',
'icon' => 'mdi:domain',
'module' => 'sites',
'permission' => 'sites.view',
],
```
3. `make cache-clear` + relancer `make dev-nuxt`, verifier que la sidebar affiche toujours "Sites".
**Fichiers :**
- `frontend/i18n/locales/fr.json`
- `config/sidebar.php`
---
### T-012 — Rendre `UserPasswordHasherProcessor` et `MeProvider` `final`
**Pourquoi :** convention implicite de la PR : tous les services sont `final readonly`. Deux classes echappent a la regle sans raison. Les garder ouvertes a l'heritage invite un futur bug par sous-classe qui oublierait d'appeler `parent::process()`.
**A faire :**
1. `src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserPasswordHasherProcessor.php:16` :
```php
// AVANT
class UserPasswordHasherProcessor implements ProcessorInterface
// APRES
final class UserPasswordHasherProcessor implements ProcessorInterface
```
2. `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/MeProvider.php:14` :
```php
// AVANT
class MeProvider implements ProviderInterface
// APRES
final class MeProvider implements ProviderInterface
```
3. Lancer `make test` — doit passer sans regression.
**Fichiers :**
- `src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserPasswordHasherProcessor.php`
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/MeProvider.php`
---
### T-013 — Extraire `debounce` dans `frontend/shared/utils/`
**Pourquoi :** la fonction `debounce` est definie inline dans `audit-log.vue`. Si une autre page en ajoute une, on aura du code duplique. `frontend/shared/utils/` existe deja (ex: `color.ts`), c'est l'endroit naturel pour ce genre d'util.
**A faire :**
1. Creer `frontend/shared/utils/debounce.ts` :
```typescript
/**
* Cree une version debounced de `fn` : chaque appel reset un timer et
* l'execution reelle attend `delay` ms apres la derniere invocation.
* Utile pour eviter de spam une recherche a chaque touche.
*/
export function debounce<T extends (...args: never[]) => void>(fn: T, delay: number): T {
let timer: ReturnType<typeof setTimeout> | null = null
return ((...args: Parameters<T>) => {
if (null !== timer) clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}) as T
}
```
2. Creer `frontend/shared/utils/__tests__/debounce.test.ts` :
```typescript
import { describe, it, expect, vi } from 'vitest'
import { debounce } from '../debounce'
describe('debounce', () => {
it('attend delay ms avant d\'appeler fn une seule fois', async () => {
vi.useFakeTimers()
const fn = vi.fn()
const debounced = debounce(fn, 100)
debounced()
debounced()
debounced()
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(100)
expect(fn).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
})
```
3. Dans `frontend/modules/core/pages/admin/audit-log.vue`, supprimer la definition locale (lignes 306-312) et laisser l'auto-import Nuxt faire son travail (les utils de `frontend/shared/utils/` sont scannes par `nuxt.config.ts` dans `imports.dirs`).
4. `make nuxt-test` doit passer.
**Fichiers :**
- `frontend/shared/utils/debounce.ts` (nouveau)
- `frontend/shared/utils/__tests__/debounce.test.ts` (nouveau)
- `frontend/modules/core/pages/admin/audit-log.vue`
---
### T-014 — Ajouter les paliers `month` et `year` a `relativeDate`
**Pourquoi :** la fonction qui affiche "il y a X minutes/heures/jours/semaines" plafonne a la semaine. Une entree d'audit vieille d'un an apparait comme "il y a 52 semaines" — peu lisible.
**A faire :**
1. Ouvrir `frontend/shared/components/audit/AuditTimeline.vue`, vers ligne 171-182.
2. Etendre la fonction `relativeDate` :
```typescript
// AVANT
function relativeDate(iso: string): string {
const diffMs = Date.now() - new Date(iso).getTime()
const diffSec = Math.round(diffMs / 1000)
const absSec = Math.abs(diffSec)
const fmt = rtf.value
if (absSec < 60) return fmt.format(-Math.sign(diffSec) * Math.abs(diffSec), 'second')
if (absSec < 3600) return fmt.format(-Math.sign(diffSec) * Math.round(absSec / 60), 'minute')
if (absSec < 86400) return fmt.format(-Math.sign(diffSec) * Math.round(absSec / 3600), 'hour')
if (absSec < 604800) return fmt.format(-Math.sign(diffSec) * Math.round(absSec / 86400), 'day')
return fmt.format(-Math.sign(diffSec) * Math.round(absSec / 604800), 'week')
}
```
```typescript
// APRES
function relativeDate(iso: string): string {
const diffMs = Date.now() - new Date(iso).getTime()
const diffSec = Math.round(diffMs / 1000)
const absSec = Math.abs(diffSec)
const sign = -Math.sign(diffSec)
const fmt = rtf.value
if (absSec < 60) return fmt.format(sign * absSec, 'second')
if (absSec < 3600) return fmt.format(sign * Math.round(absSec / 60), 'minute')
if (absSec < 86400) return fmt.format(sign * Math.round(absSec / 3600), 'hour')
if (absSec < 604800) return fmt.format(sign * Math.round(absSec / 86400), 'day')
if (absSec < 2592000) return fmt.format(sign * Math.round(absSec / 604800), 'week') // < ~30j
if (absSec < 31536000) return fmt.format(sign * Math.round(absSec / 2592000), 'month') // < ~365j
return fmt.format(sign * Math.round(absSec / 31536000), 'year')
}
```
3. `make dev-nuxt`, ouvrir un drawer avec un vieux log (ou modifier une date pour tester).
**Fichiers :** `frontend/shared/components/audit/AuditTimeline.vue`
---
### T-015 — Traduire `entityType` dans le drawer de detail d'audit
**Pourquoi :** le titre du drawer affiche `core.User #42` brut. La spec prevoit un lookup i18n pour afficher un nom lisible (ex: "Utilisateur #42"). Les cles sont deja prevues (`audit.entity.user` existe), il suffit d'en ajouter et de les utiliser.
**A faire :**
1. Completer `frontend/i18n/locales/fr.json` avec les cles manquantes :
```json
"audit": {
...
"entity": {
"core_user": "Utilisateur",
"core_role": "Rôle",
"core_permission": "Permission",
"sites_site": "Site"
},
...
}
```
(remplace l'ancienne cle `audit.entity.user` qui n'etait que pour `User`)
2. Dans `frontend/modules/core/pages/admin/audit-log.vue`, ajouter une fonction helper :
```typescript
const { t, te } = useI18n()
function formatEntityType(type: string): string {
// Convertit "core.User" -> "core_user" pour matcher la cle i18n
const key = `audit.entity.${type.toLowerCase().replace(/\./g, '_')}`
return te(key) ? t(key) : type
}
```
3. Modifier le template du drawer (ligne 138) :
```html
<!-- AVANT -->
<h3 class="text-sm font-medium text-gray-700 mb-2">
{{ selectedEntry.entityType }} #{{ selectedEntry.entityId }}
</h3>
<!-- APRES -->
<h3 class="text-sm font-medium text-gray-700 mb-2">
{{ formatEntityType(selectedEntry.entityType) }} #{{ selectedEntry.entityId }}
</h3>
```
4. Tester : ouvrir un drawer sur un audit de User, Role, Site — verifier l'affichage.
**Fichiers :**
- `frontend/i18n/locales/fr.json`
- `frontend/modules/core/pages/admin/audit-log.vue`
---
### T-016 — Logger un warning si `UserRbacProcessor` recoit un body JSON invalide
**Pourquoi :** le processor parse `$request->getContent()` pour savoir quelles cles sont absentes du payload PATCH et restaurer les collections. Si le JSON est invalide (rare mais possible), la restauration est silencieusement sautee — les collections RBAC du user peuvent etre ecrasees par des arrays vides. Impossible a debugger sans log.
**A faire :**
1. Ouvrir `src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php`.
2. S'assurer que le logger est injecte dans le constructeur (l'ajouter s'il manque) :
```php
use Psr\Log\LoggerInterface;
public function __construct(
// ... autres dependances
private readonly LoggerInterface $logger,
) {}
```
3. Modifier la methode (vers ligne 241-248) :
```php
// AVANT
$payload = json_decode($request->getContent(), true);
if (!is_array($payload)) {
return;
}
```
```php
// APRES
$payload = json_decode($request->getContent(), true);
if (!is_array($payload)) {
$this->logger->warning(
'UserRbacProcessor : body JSON invalide, restoreAbsentCollections saute. '
.'Les collections RBAC peuvent etre ecrasees silencieusement.',
['user_id' => $data->getId()],
);
return;
}
```
4. Lancer `make test`.
**Fichiers :** `src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php`
---
## P3 — Polish et dette technique
### T-017 — Mettre a jour `CHANGELOG.md`
**Pourquoi :** le changelog est toujours a `## [0.0.0]` avec un contenu pre-PR. Sans changelog a jour, c'est dur pour un futur dev (ou pour soi-meme dans 6 mois) de savoir ce qui a change.
**A faire :**
1. Remplacer le contenu actuel de `CHANGELOG.md` par (adapter la version au bump effectif) :
```markdown
# Changelog
Liste des evolutions du projet Coltura.
## [0.1.34] - 2026-04-XX
### Added
- Systeme d'audit log (table append-only, API read-only, page admin, timeline)
- Module Sites (multi-tenant, scope automatique, drawer admin)
- Systeme RBAC complet (Role, Permission, UserRbacDrawer, AdminHeadcountGuard)
- Suite E2E Playwright (6 personas, matrice RBAC sidebar)
- Tests PHPUnit pour Sites et Audit
### Changed
- Arborescence frontend : `shared/` + `modules/*/` en layers Nuxt
- Migrations d'init au namespace racine (bug tri FQCN Doctrine 3.x)
### Fixed
- UserRbacProcessor : restauration des collections absentes du payload PATCH
- Drawer utilisateurs : chargement RBAC via `/api/users/{id}/rbac`
- Audit UI : guard stale-data sur fetch concurrent
## [0.0.0]
... contenu initial ...
```
**Fichiers :** `CHANGELOG.md`
---
### T-018 — Supprimer `id` hardcode de `AuditLogEntityTypesResource`
**Pourquoi :** la propriete `$id = 'entity-types'` n'est pas utilisee par le provider. C'est du bruit dans le DTO qui peut confondre un futur lecteur.
**A faire :**
1. Ouvrir `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php`.
2. Supprimer la ligne `public readonly string $id = 'entity-types';` (ligne 31).
3. S'assurer que `AuditLogEntityTypesResource` n'a plus besoin de cet `$id` nulle part. Si API Platform leve une erreur pour l'absence d'identifier, remettre la propriete mais avec un commentaire explicite expliquant qu'elle est requise par API Platform pour les ressources singleton.
4. `make test` pour valider.
**Fichiers :** `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php`
---
### T-019 — Conditionner `loadSidebar()` apres switch de site
**Pourquoi :** apres chaque switch de site, `useCurrentSite` recharge la sidebar — mais la sidebar de Coltura ne depend d'aucun site. C'est un aller-retour reseau inutile par switch (~100ms + possible flicker visuel).
**A faire :**
1. Ouvrir `frontend/modules/sites/composables/useCurrentSite.ts` (vers ligne 94).
2. Deux options :
*Option A — Supprimer le rechargement* (preferee si la sidebar ne depend actuellement d'aucun site) :
```typescript
// AVANT
await loadSidebar()
// APRES
// Aucun item de sidebar ne depend du site courant aujourd'hui. Si un
// module futur expose des items site-scoped, reintroduire ce reload
// (et le tester).
```
*Option B — Garder le reload avec commentaire explicite* :
```typescript
// Reload defensif : prepare le cas ou /api/sidebar devient site-scoped
// (ex: items RH ou comptabilite par site). Cout : 1 RTT par switch.
await loadSidebar()
```
3. Si option A : tester en switchant de site, verifier qu'il n'y a pas de lag ni de flicker.
**Fichiers :** `frontend/modules/sites/composables/useCurrentSite.ts`
---
### T-020 — Supprimer l'alias `SiteNotAuthorizedException`
**Pourquoi :** la classe `App\Module\Sites\Domain\Exception\SiteNotAuthorizedException` est un alias `final` vide qui etend celle de `Shared\Domain\Exception\`. Elle est dette technique non planifiee — si quelqu'un la referencait dans un nouveau code, on aurait deux versions flottantes.
**A faire :**
1. Chercher les usages :
```bash
grep -rn 'App\\Module\\Sites\\Domain\\Exception\\SiteNotAuthorizedException' src/ tests/
```
2. Remplacer chaque usage par l'import de `Shared\Domain\Exception\SiteNotAuthorizedException`.
3. Supprimer le fichier `src/Module/Sites/Domain/Exception/SiteNotAuthorizedException.php`.
4. `make test` pour verifier.
**Fichiers :**
- `src/Module/Sites/Domain/Exception/SiteNotAuthorizedException.php` (a supprimer)
- Tous les fichiers qui l'importent (grep)
---
### T-021 — Reduire le couplage Core → Sites (PHPDoc `User`, fixtures, SeedE2ECommand)
**Pourquoi :** la regle projet interdit l'import direct d'un module vers un autre. Dans cette PR, `User.php`, `AppFixtures.php` et `SeedE2ECommand.php` importent respectivement `Site`, `SiteRepositoryInterface`, `SitesFixtures`. C'est documente comme "intentionnel" mais ca viole la regle, donc a documenter plus officiellement ou corriger.
**A faire :** (ticket dette technique — pas urgent, mais a prevoir)
1. Pour `User.php:23` — remplacer les PHPDoc `@var Collection<int, Site>` par `@var Collection<int, SiteInterface>` (l'interface existe deja dans `Shared/Domain/Contract/`).
2. Pour `AppFixtures.php` et `SeedE2ECommand.php` — extraire une interface `SiteFixturesInterface` dans `Shared/` si le couplage est vraiment genant. Sinon, documenter l'exception dans `CLAUDE.md` avec une phrase du type *"Les fixtures et seeds peuvent importer des repositories de modules metiers — c'est accepte car ils vivent hors domaine."*
3. Pas de correction de code urgente. Ouvrir un TODO/issue interne.
**Fichiers :**
- `src/Module/Core/Domain/Entity/User.php`
- `src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php`
- `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php`
- `.claude/rules/architecture.md` (possibles maj)
---
### T-022 — Offrir un bouton "Reessayer" sur les drawers en erreur
**Pourquoi :** si `UserRbacDrawer`, `RoleDrawer` ou `SiteDrawer` echoue son fetch initial (403, 500, reseau), l'utilisateur doit fermer et rouvrir le drawer pour relancer. Un bouton "Reessayer" dans l'etat `loadFailed` ameliore l'UX.
**A faire :** (non bloquant)
1. Dans chaque drawer, ajouter dans le template quand `loadFailed.value === true` :
```vue
<div v-if="loadFailed" class="flex flex-col items-center gap-3 p-4">
<p class="text-sm text-red-600">{{ t('errors.http.get') }}</p>
<MalioButton
type="secondary"
size="sm"
:label="t('common.retry')"
@click="loadData(props.user?.id)"
/>
</div>
```
2. Ajouter `"retry": "Reessayer"` sous `common` dans `fr.json`.
3. Tester : couper reseau, ouvrir drawer, verifier bouton — remettre reseau, cliquer, verifier rechargement.
**Fichiers :**
- `frontend/modules/core/components/UserRbacDrawer.vue`
- `frontend/modules/core/components/RoleDrawer.vue`
- `frontend/modules/sites/components/SiteDrawer.vue`
- `frontend/i18n/locales/fr.json`
---
### T-023 — Garder contre double execution `onMounted` dans `logout.vue`
**Pourquoi :** si la page `logout` est visitee deux fois rapidement, `auth.logout()` est appele deux fois. Lexik est idempotent cote backend donc sans danger, mais deux toasts d'erreur peuvent apparaitre si le reseau flappe. Un simple guard evite ca.
**A faire :**
1. Ouvrir `frontend/modules/core/pages/logout.vue`.
2. Ajouter un guard module-level :
```vue
<script setup lang="ts">
definePageMeta({ layout: 'auth' })
const auth = useAuthStore()
const { resetSidebar } = useSidebar()
const { resetModules } = useModules()
const { resetCurrentSite } = useCurrentSite()
const { resetAuditLog } = useAuditLog()
let logoutInProgress = false
onMounted(async () => {
if (logoutInProgress) return
logoutInProgress = true
try {
await auth.logout()
} finally {
resetSidebar()
resetModules()
resetCurrentSite()
resetAuditLog()
await navigateTo('/login')
}
})
</script>
```
**Fichiers :** `frontend/modules/core/pages/logout.vue`
---
## Resume
| Priorite | Tickets | Estimation |
|----------|--------------|------------|
| **P0** | T-001 a T-003 | ~45 min total (3 tickets de config/fichier simple) |
| **P1** | T-004 a T-009 | ~3h (fixes logique + tests + verif) |
| **P2** | T-010 a T-016 | ~2h30 (conventions + refacto legers) |
| **P3** | T-017 a T-023 | ~1h30 (polish + dette) |
| **Total**| 23 tickets | ~7h30 |
> Commence par T-001 — c'est le plus rapide ET le plus impactant (simple suppression d'une ligne).
> Apres les P0, les P1 sont prioritaires : T-007 (flag singleton), T-008 (pagination) et T-004 (crash 500) sont ceux qui peuvent casser en production.
> Pour chaque ticket, fais un commit dedie : `fix(T-XXX) : description courte`.
> Les regles de commit du projet s'appliquent : francais, type + scope + espaces autour du `:`, pas de mention d'outil d'assistance.