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

37 KiB

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 :
# 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.

  1. Recharger : make cache-clear puis make restart.
  2. 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; :
# 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).
  1. Recharger Nginx : docker restart nginx-coltura (ou celui qui fait office de proxy public).
  2. 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 :
use Doctrine\DBAL\Types\Types;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
  1. Dans applyFilters(), remplacer les blocs performed_at_after et performed_at_before :
// 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']);
}
// 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);
}
  1. Tester : curl -i -b cookie.txt 'http://localhost:8083/api/audit-logs?performed_at%5Bafter%5D=pasunedate' doit retourner 400 Bad Request, plus 500.
  2. 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 :
// 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.'%')
    ;
}
// 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.

  1. Tester en psql : creer une entree avec performed_by = 'admin_backup', puis :
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) :
// 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.'
        );
    }
}
// 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.',
        );
    }
}
  1. 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) :
// AVANT
if (!isLoginCheck && !isLogout) {
    if (!isHandlingUnauthorized) {
        isHandlingUnauthorized = true
        auth.clearSession()
        await navigateTo('/login')
        isHandlingUnauthorized = false
    }
}
// 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
        }
    }
}
  1. 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) :

// 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) :

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

  1. Frontend — Retirer les itemsPerPage: 999.

frontend/modules/core/components/UserRbacDrawer.vue:235-236 :

// 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 :

// 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 :

// AVANT
const data = await api.get<{ member: Site[] }>(
    '/sites',
    { itemsPerPage: 999 },
    { toast: false },
)

// APRES
const data = await api.get<{ member: Site[] }>(
    '/sites',
    {},
    { toast: false },
)
  1. Tester : ouvrir le UserRbacDrawer, verifier que toutes les permissions et tous les sites s'affichent. Idem sur RoleDrawer et la page admin/sites.

  2. 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 :
// 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)
}
// 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 :
<!-- AVANT -->
<button
    type="button"
    class="mt-3 text-sm text-blue-600 hover:text-blue-800"
    @click="loadMore"
>
    {{ t('audit.timeline.load_more') }}
</button>
<!-- 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 :

<!-- TODO(malio-ui) : pas de variant 'link-inline' dispo dans @malio/layer-ui 1.4.2 -->
<button type="button" ...>
  1. 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 :
// AVANT
"sidebar": {
    "core": {
        "roles": "Gestion des rôles",
        "users": "Utilisateurs",
        "sites": "Sites",
        "audit_log": "Journal d'audit"
    }
}
// APRES
"sidebar": {
    "core": {
        "roles": "Gestion des rôles",
        "users": "Utilisateurs",
        "audit_log": "Journal d'audit"
    },
    "sites": {
        "admin": "Sites"
    }
}
  1. Ouvrir config/sidebar.php, mettre a jour le label de l'item Sites :
// AVANT
[
    'label'      => 'sidebar.core.sites',
    'to'         => '/admin/sites',
    'icon'       => 'mdi:domain',
    'module'     => 'sites',
    'permission' => 'sites.view',
],
// APRES
[
    'label'      => 'sidebar.sites.admin',
    'to'         => '/admin/sites',
    'icon'       => 'mdi:domain',
    'module'     => 'sites',
    'permission' => 'sites.view',
],
  1. 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 :
// AVANT
class UserPasswordHasherProcessor implements ProcessorInterface

// APRES
final class UserPasswordHasherProcessor implements ProcessorInterface
  1. src/Module/Core/Infrastructure/ApiPlatform/State/Provider/MeProvider.php:14 :
// AVANT
class MeProvider implements ProviderInterface

// APRES
final class MeProvider implements ProviderInterface
  1. 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 :
/**
 * 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
}
  1. Creer frontend/shared/utils/__tests__/debounce.test.ts :
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()
    })
})
  1. 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).

  2. 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 :
// 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')
}
// 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')
}
  1. 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 :
"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)

  1. Dans frontend/modules/core/pages/admin/audit-log.vue, ajouter une fonction helper :
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
}
  1. Modifier le template du drawer (ligne 138) :
<!-- 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>
  1. 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) :
use Psr\Log\LoggerInterface;

public function __construct(
    // ... autres dependances
    private readonly LoggerInterface $logger,
) {}
  1. Modifier la methode (vers ligne 241-248) :
// AVANT
$payload = json_decode($request->getContent(), true);
if (!is_array($payload)) {
    return;
}
// 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;
}
  1. 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) :
# 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) :

// 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 :

// 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()
  1. 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 :
grep -rn 'App\\Module\\Sites\\Domain\\Exception\\SiteNotAuthorizedException' src/ tests/
  1. Remplacer chaque usage par l'import de Shared\Domain\Exception\SiteNotAuthorizedException.

  2. Supprimer le fichier src/Module/Sites/Domain/Exception/SiteNotAuthorizedException.php.

  3. 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 :
<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>
  1. Ajouter "retry": "Reessayer" sous common dans fr.json.

  2. 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 :
<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.