feat : audit log (table + writer + listener + API + admin UI + timeline) #9

Merged
matthieu merged 38 commits from feat/audit-log into develop 2026-05-13 08:29:31 +00:00
Owner

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

  • make test : 218 tests passent (writer unitaires + listener intégration + API fonctionnels + UserRbacProcessor).
  • npm run lint + npm run test + npm run build (frontend).
  • Migration appliquée sur dev + test, audit_log ignoré par schema_filter.
  • Permissions synchronisées (app:sync-permissions).
  • 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).
## 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).
matthieu self-assigned this 2026-04-20 18:52:03 +00:00
matthieu added 1 commit 2026-04-20 18:52:03 +00:00
Implemente le journal d'audit append-only sur toutes les mutations Doctrine
des entites portant #[Auditable]. Couvre les 5 tickets de doc/audit-log.md :

1. Table PG audit_log (uuid PK, jsonb changes, index entity/time/performer)
   + AuditLogWriter (DBAL connexion dediee audit, blacklist defense-in-depth
   sur password/plainPassword/token/secret) + RequestIdProvider (UUID v4 par
   requete HTTP principale).
2. Attributs Auditable / AuditIgnore dans Shared/Domain/Attribute/
   + AuditListener (onFlush capture + postFlush ecriture hors transaction ORM,
   pattern swap-and-clear, erreurs loguees jamais propagees). User annote.
3. API Platform read-only /api/audit-logs (permission core.audit_log.view)
   avec filtres entity_type / entity_id / action / performed_by / plage
   performed_at + DbalPaginator implementant PaginatorInterface (hydra:view
   genere automatiquement).
4. Page admin /admin/audit-log : tableau pagine, filtres persistes en query
   params, row expandable (diff + timeline de l'entite), entree sidebar avec
   permission. Composable useAuditLog avec resetAuditLog() auto-enregistre
   sur onAuthSessionCleared.
5. Composant AuditTimeline reutilisable : garde permission, lazy loading,
   dates relatives FR, skeleton loader.

Fix connexe : phpunit.dist.xml forcait APP_ENV=dev via <env> ce qui cablait
framework.test=false et rendait test.service_container indisponible ; le
JWT_PASSPHRASE ne matchait pas non plus les cles dev. Corrige en meme temps
pour debloquer la suite de tests.
matthieu added 1 commit 2026-04-20 19:11:09 +00:00
Blocker
- Frontend attendait `hydra:member` / `hydra:totalItems` / `hydra:view` mais
  API Platform 4 sert `member` / `totalItems` / `view` (sans prefixe) sous
  ld+json, et un tableau plat sous json. Consequence : tableau admin et
  timeline silencieusement vides.
  Fix : `useAuditLog` force `Accept: application/ld+json` (necessaire pour
  obtenir l'objet Hydra avec pagination), types `HydraCollection`/`HydraView`
  renommes, composants accedent aux proprietes sans prefixe. Nouveau test
  fonctionnel verrouille le format.

Should-fix
- `AuditLogWriter` : ajout de `'id' => Types::GUID` pour expliciter le type
  natif PG `uuid` (fonctionnait par cast implicite mais l'intention etait
  floue).
- `AuditListener` docblock : documente que le DQL bulk DELETE/UPDATE et
  `Connection::executeStatement()` bypassent le listener (onFlush non
  appele). Piege pour les futures commandes de purge.
- `AuditLogResource` : ajout d'une regex UUID dans `requirements` de
  l'operation Get — un `GET /api/audit-logs/not-a-uuid` produisait un 500
  (cast PG rejete) au lieu d'un 404.
- `audit-log.vue` : le watcher des filtres faisait `filters.page = 1` ce
  qui declenchait le watcher de `page`, causant deux `loadEntries()` en
  parallele. Fusionne : la navigation page appelle `loadEntries()`
  directement depuis `goPrevious`/`goNext`, plus de watcher dedie.
- `useAuditLog.fetchEntityLogs` : bypass du cache `lastCollection` pour ne
  pas polluer la reference page-level quand la timeline est ouverte.
- `AuditTimeline.vue` : remplacement du `<div v-if="!canView"/>` vide par
  un `v-if` sur le wrapper — aucun DOM quand l'utilisateur n'a pas le droit.
- `AuditListenerTest` tag : retire le `_` (wildcard LIKE SQL) du prefix
  pour eviter un faux negatif de match cross-test.
- `AuditLogApiTest` : proprietes `auditConnection` / `runTag` nullable et
  tearDown guarde, sinon un echec setUp provoquait un fatal typed-property
  au lieu de propager l'exception d'origine.

Stabilite suite de tests
- `doctrine.yaml when@test` : `idle_connection_ttl: 1` sur les deux
  connexions pour eviter l'accumulation de connexions orphelines.
- tearDown des tests audit : `close()` explicite sur la connexion audit
  apres chaque test.
- `docker-compose.yml` : `max_connections=300` sur la DB dev (defaut PG=100
  insuffisant pour 220+ tests * 2 connexions/test).
Author
Owner

Code review

Found 5 issues:

  1. AuditListener bypasse silencieusement l'audit sur les entites chargees en lazy (proxies Doctrine)$class = $entity::class (ligne 155) renvoie Proxies\__CG__\App\Module\Core\Domain\Entity\User sur une entite lazy-loaded. isAuditable() (ligne 258) construit new ReflectionClass($class)->getAttributes(Auditable::class) sans remonter la classe parente, donc #[Auditable] n'est pas detecte et la mutation n'est pas auditee. Cas tres frequent en pratique (toute modification via navigation de relation). Viole directement la regle du CLAUDE.md : "Audit obligatoire : toute entite doit porter #[Auditable]". Fix : Doctrine\Common\Util\ClassUtils::getRealClass($class) ou $em->getClassMetadata($entity)->getName().

private function capturePendingLog(object $entity, EntityManagerInterface $em, UnitOfWork $uow, string $action): void
{
$class = $entity::class;
if (!$this->isAuditable($class)) {
return;
}
$metadata = $em->getClassMetadata($class);

  1. AuditTimeline saute les items 11 a 30 de la premiere page — L'API retourne 30 items/page (paginationItemsPerPage: 30 sur AuditLogResource), le composant slice a INITIAL_LIMIT = 10 au premier chargement (ligne 121), puis loadMore() incremente page.value + 1 (ligne 134) pour fetch la page 2 (items 31-60). Les items 11-30 ne sont jamais affiches — l'historique d'une entite avec plus de 10 evenements sera incoherent. Fix : soit charger avec itemsPerPage=10 cote backend, soit augmenter le slice quand l'utilisateur clique "Voir plus" sur la meme page.

async function loadPage(targetPage: number, append: boolean): Promise<void> {
if (!canView.value) return
loading.value = true
try {
const data = await fetchEntityLogs(entityType.value, entityId.value, targetPage)
const slice = (data.member ?? []).slice(0, append ? undefined : INITIAL_LIMIT)
entries.value = append ? [...entries.value, ...slice] : slice
totalItems.value = data.totalItems ?? entries.value.length
page.value = targetPage
} catch {
// Erreur silencieuse (timeline secondaire) — useApi n'affiche pas de toast avec toast: false.
entries.value = append ? entries.value : []
} finally {
loading.value = false
}
}
async function loadMore(): Promise<void> {
await loadPage(page.value + 1, true)
}

  1. Page audit-log.vue persiste l'etat du tableau dans l'URL — Les filtres et le numero de page sont initialises depuis route.query via readQuery() (lignes 212-217) et resynchronises via router.replace({ query }) dans syncQuery() (ligne 340). Contraire a la convention projet "Tableaux : pas de persistance URL — aucun etat de tableau (filtres, pagination, tri...) ne doit etre persiste dans la query string ou reinjecte depuis route.query au montage". Seuls les deep links metier (ex: /users/42) sont dans l'URL.

const filters = reactive<AuditLogFilters>({
performedAtAfter: readQuery('after'),
performedAtBefore: readQuery('before'),
entityType: readQuery('entity_type'),
performedBy: readQuery('performed_by'),
action: readQuery('action'),
page: Number(readQuery('page') ?? 1) || 1,
})
// Les checkboxes d'action fonctionnent en multi-select cote UI mais l'API

  1. Tableau admin en <table> brut avec pagination custom — La liste /admin/audit-log utilise un <table> hand-rolled avec <thead>/<tbody> (ligne 83) et une pagination custom via goPrevious/goNext au lieu de MalioDataTable. Contraire a la convention "Tableaux de donnees : utiliser MalioDataTable — tout affichage LISTE tabulaire (donnees metier paginees, CRUD admin) doit passer par MalioDataTable". L'exception "presentationnel non-paginable" ne s'applique pas ici.

<!-- Tableau -->
<section class="mt-4 rounded border border-gray-200 bg-white overflow-hidden">
<table class="min-w-full text-sm">
<thead class="bg-tertiary-500 text-white">
<tr>
<th class="px-3 py-2 text-left font-medium">
{{ t('admin.auditLog.table.performedAt') }}
</th>
<th class="px-3 py-2 text-left font-medium">
{{ t('admin.auditLog.table.performedBy') }}
</th>
<th class="px-3 py-2 text-left font-medium">
{{ t('admin.auditLog.table.entityType') }}
</th>
<th class="px-3 py-2 text-left font-medium">
{{ t('admin.auditLog.table.entityId') }}
</th>
<th class="px-3 py-2 text-left font-medium">
{{ t('admin.auditLog.table.action') }}
</th>
<th class="px-3 py-2 text-left font-medium">
{{ t('admin.auditLog.table.summary') }}
</th>
</tr>
</thead>
<tbody>
<template v-if="entries.length > 0">
<template v-for="entry in entries" :key="entry.id">
<tr
class="border-t border-gray-100 hover:bg-gray-50 cursor-pointer"
@click="toggleExpand(entry.id)"
>
<td class="px-3 py-2">
{{ formatDate(entry.performedAt) }}
</td>
<td class="px-3 py-2">{{ entry.performedBy }}</td>
<td class="px-3 py-2 font-mono text-xs">{{ entry.entityType }}</td>
<td class="px-3 py-2 font-mono text-xs">{{ entry.entityId }}</td>
<td class="px-3 py-2">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
:class="actionBadgeClass(entry.action)"
>
{{ t(`audit.action.${entry.action}`) }}
</span>
</td>
<td class="px-3 py-2 text-xs text-gray-600">
{{ summarize(entry) }}
</td>
</tr>
<!-- Detail expandable : diff courant + timeline complete de l'entite. -->
<tr v-if="expandedId === entry.id" class="bg-gray-50">
<td colspan="6" class="px-3 py-3">
<AuditLogDetail :entry="entry" />
<div class="mt-4 border-t border-gray-200 pt-3">
<h3 class="text-sm font-medium text-gray-700 mb-2">
{{ entry.entityType }} #{{ entry.entityId }}
</h3>
<AuditTimeline
:entity-type="entry.entityType"
:entity-id="entry.entityId"
/>
</div>
</td>
</tr>
</template>
</template>
<tr v-else-if="!loading">
<td colspan="6" class="px-3 py-6 text-center text-sm text-gray-500">
{{ isFiltered ? t('audit.no_results') : t('audit.empty') }}
</td>
</tr>
<tr v-else>
<td colspan="6" class="px-3 py-6 text-center text-sm text-gray-500">
{{ t('common.loading') }}
</td>
</tr>
</tbody>
</table>
</section>
<!-- Pagination via hydra (view.next / view.previous) -->
<nav class="mt-3 flex items-center justify-between text-sm">
<span class="text-gray-600">
{{ totalItems }} entrée{{ totalItems > 1 ? 's' : '' }}
</span>
<div class="flex gap-2">
<button
type="button"
class="px-3 py-1 rounded border border-gray-300 disabled:opacity-60"

  1. Champs de filtre en <input> / <button> bruts sans composants Malio* — Les filtres "Entite" (<input type="text"> ligne 36), "Utilisateur" (ligne 47), les checkboxes d'actions (ligne 59), le bouton Reset (ligne 71) et les deux <input type="datetime-local"> (lignes 16, 26) ne passent pas par MalioInputText / MalioSelectCheckbox / MalioCheckbox / MalioButton. Convention projet "Composants formulaires : utiliser @malio/layer-ui — tout champ de formulaire / filtre doit utiliser les composants Malio*". Les exceptions legitimes (datetime-local) doivent porter un commentaire TODO explicite.

{{ t('audit.filters.date_from') }}
</label>
<input
v-model="filters.performedAtAfter"
type="datetime-local"
class="mt-1 w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">
{{ t('audit.filters.date_to') }}
</label>
<input
v-model="filters.performedAtBefore"
type="datetime-local"
class="mt-1 w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">
{{ t('audit.filters.entity_type') }}
</label>
<input
v-model="filters.entityType"
type="text"
placeholder="core.User"
class="mt-1 w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">
{{ t('audit.filters.user') }}
</label>
<input
v-model="filters.performedBy"
type="text"
class="mt-1 w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">
{{ t('audit.filters.action') }}
</label>
<div class="mt-1 flex flex-wrap gap-2">
<label v-for="a in allActions" :key="a" class="flex items-center gap-1 text-xs">
<input
type="checkbox"
:checked="selectedActions.includes(a)"
@change="toggleAction(a)"
>
{{ t(`audit.action.${a}`) }}
</label>
</div>
</div>
</div>
<div class="mt-3 flex justify-end">
<button
type="button"
class="px-3 py-1 text-xs rounded border border-gray-300 hover:bg-gray-50"
@click="resetFilters"

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

### Code review Found 5 issues: 1. **`AuditListener` bypasse silencieusement l'audit sur les entites chargees en lazy (proxies Doctrine)** — `$class = $entity::class` (ligne 155) renvoie `Proxies\__CG__\App\Module\Core\Domain\Entity\User` sur une entite lazy-loaded. `isAuditable()` (ligne 258) construit `new ReflectionClass($class)->getAttributes(Auditable::class)` sans remonter la classe parente, donc `#[Auditable]` n'est pas detecte et la mutation n'est pas auditee. Cas tres frequent en pratique (toute modification via navigation de relation). Viole directement la regle du CLAUDE.md : "Audit obligatoire : toute entite doit porter `#[Auditable]`". Fix : `Doctrine\Common\Util\ClassUtils::getRealClass($class)` ou `$em->getClassMetadata($entity)->getName()`. https://gitea.malio.fr/MALIO-DEV/Coltura/src/commit/37eafd276c60bc47228850cca43f6c0342ef27f2/src/Module/Core/Infrastructure/Doctrine/AuditListener.php#L152-L162 2. **`AuditTimeline` saute les items 11 a 30 de la premiere page** — L'API retourne 30 items/page (`paginationItemsPerPage: 30` sur `AuditLogResource`), le composant slice a `INITIAL_LIMIT = 10` au premier chargement (ligne 121), puis `loadMore()` incremente `page.value + 1` (ligne 134) pour fetch la page 2 (items 31-60). Les items 11-30 ne sont jamais affiches — l'historique d'une entite avec plus de 10 evenements sera incoherent. Fix : soit charger avec `itemsPerPage=10` cote backend, soit augmenter le slice quand l'utilisateur clique "Voir plus" sur la meme page. https://gitea.malio.fr/MALIO-DEV/Coltura/src/commit/37eafd276c60bc47228850cca43f6c0342ef27f2/frontend/shared/components/audit/AuditTimeline.vue#L115-L135 3. **Page `audit-log.vue` persiste l'etat du tableau dans l'URL** — Les filtres et le numero de page sont initialises depuis `route.query` via `readQuery()` (lignes 212-217) et resynchronises via `router.replace({ query })` dans `syncQuery()` (ligne 340). Contraire a la convention projet "Tableaux : pas de persistance URL — aucun etat de tableau (filtres, pagination, tri...) ne doit etre persiste dans la query string ou reinjecte depuis `route.query` au montage". Seuls les deep links metier (ex: `/users/42`) sont dans l'URL. https://gitea.malio.fr/MALIO-DEV/Coltura/src/commit/37eafd276c60bc47228850cca43f6c0342ef27f2/frontend/modules/core/pages/admin/audit-log.vue#L210-L220 4. **Tableau admin en `<table>` brut avec pagination custom** — La liste `/admin/audit-log` utilise un `<table>` hand-rolled avec `<thead>`/`<tbody>` (ligne 83) et une pagination custom via `goPrevious`/`goNext` au lieu de `MalioDataTable`. Contraire a la convention "Tableaux de donnees : utiliser MalioDataTable — tout affichage LISTE tabulaire (donnees metier paginees, CRUD admin) doit passer par `MalioDataTable`". L'exception "presentationnel non-paginable" ne s'applique pas ici. https://gitea.malio.fr/MALIO-DEV/Coltura/src/commit/37eafd276c60bc47228850cca43f6c0342ef27f2/frontend/modules/core/pages/admin/audit-log.vue#L81-L170 5. **Champs de filtre en `<input>` / `<button>` bruts sans composants `Malio*`** — Les filtres "Entite" (`<input type="text">` ligne 36), "Utilisateur" (ligne 47), les checkboxes d'actions (ligne 59), le bouton Reset (ligne 71) et les deux `<input type="datetime-local">` (lignes 16, 26) ne passent pas par `MalioInputText` / `MalioSelectCheckbox` / `MalioCheckbox` / `MalioButton`. Convention projet "Composants formulaires : utiliser @malio/layer-ui — tout champ de formulaire / filtre doit utiliser les composants Malio*". Les exceptions legitimes (`datetime-local`) doivent porter un commentaire TODO explicite. https://gitea.malio.fr/MALIO-DEV/Coltura/src/commit/37eafd276c60bc47228850cca43f6c0342ef27f2/frontend/modules/core/pages/admin/audit-log.vue#L14-L74 🤖 Generated with [Claude Code](https://claude.ai/code) <sub>- If this code review was useful, please react with 👍. Otherwise, react with 👎.</sub>
matthieu added 2 commits 2026-04-21 14:29:56 +00:00
Resout les 5 findings de la review automatique + couverture ManyToMany
annoncee dans CLAUDE.md :

- AuditListener : resolution de la classe via ClassMetadata plutot que
  `$entity::class` direct (defense proxy Doctrine : sous ORM 2 les lazies
  sont des `Proxies\__CG__\...`). Test de regression via getReference().
- AuditListener : capture des modifications de collections to-many
  (OneToMany / ManyToMany) via getScheduledCollectionUpdates /
  getScheduledCollectionDeletions. Les diffs sont mergees dans le
  changeset existant ou creent une entree "update" dediee.
- AuditLogResource + Provider : filtre multi-valeurs
  `entity_type[]=X&entity_type[]=Y` (IN clause DBAL via
  ArrayParameterType::STRING), endpoint `/audit-log-entity-types` pour
  alimenter le MalioSelectCheckbox cote front.
- audit-log.vue : refonte complete. Passage a `MalioDataTable`,
  composants `Malio*` (MalioInputText, MalioSelectCheckbox, MalioButton),
  suppression complete de la persistance URL (`readQuery` / `syncQuery`
  / `route.query`). `datetime-local` conserve avec TODO pointant
  l'exception CLAUDE.md.
- AuditTimeline : fix du saut d'items 11-30. `PAGE_SIZE = 10` aligne
  avec un `itemsPerPage=10` passe au backend. Token anti-race pour
  ignorer les reponses tardives quand l'entite affichee change.
- AuditLogDetail : affichage des diffs de collections to-many (+ / -)
  dans le tableau field/old/new existant.
- logout.vue : ajout du `resetAuditLog()` au logout pour eviter qu'un
  user suivant (meme onglet) voie l'etat audit de l'ancien.
- Permission / Role / Site : marquage `#[Auditable]`.
- Version bump 0.1.32 → 0.1.34.

Tests : 228 / 228 (221 assertions → 851, dont regressions proxy + M2M).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author
Owner

Fixes appliquees

Commits a95bb6c + 1505e84 — 228/228 tests verts.

  1. Proxy bypass AuditListener → resolution via $em->getClassMetadata($entity::class)->getName() appliquee aux deux sites (capturePendingLog + captureCollectionChange). Regression test testLogsUpdateOnProxyEntity via getReference().

private function capturePendingLog(object $entity, EntityManagerInterface $em, UnitOfWork $uow, string $action): void
{
// Resolution via ClassMetadata : `$entity::class` renvoie le FQCN du
// proxy Doctrine pour une entite chargee en lazy (ex:
// `Proxies\__CG__\App\Module\Core\Domain\Entity\User`) — `isAuditable()`
// le verrait comme non-auditable car `#[Auditable]` n'est declare que
// sur la classe parente.
$metadata = $em->getClassMetadata($entity::class);
$class = $metadata->getName();
if (!$this->isAuditable($class)) {
return;
}

  1. AuditTimeline items 11-30PAGE_SIZE = 10 cote composant, itemsPerPage=10 passe au backend via fetchEntityLogs(entityType, entityId, page, itemsPerPage). Token anti-race en bonus.

// Lazy loading : 10 items par page cote UX. On aligne la pagination backend
// (itemsPerPage=10 dans fetchEntityLogs) avec cette taille pour eviter de
// slicer cote client — sinon les items 11-30 de chaque page etaient ignores.
const PAGE_SIZE = 10
// Anti-race : un utilisateur qui change rapidement d'entite affichee (ouvre
// une ligne puis une autre dans le tableau admin) peut declencher deux fetchs
// dont le premier repond en retard et ecrase l'etat de la seconde timeline.
// On incremente un token a chaque fetch ; seule la derniere requete ecrit le
// resultat. loadMore() est aussi protege : une reponse tardive append sur
// une timeline dont l'entite a deja change serait visuellement confuse.
let requestToken = 0
const hasMore = computed(() => entries.value.length < totalItems.value)
async function loadPage(targetPage: number, append: boolean): Promise<void> {
if (!canView.value) return
const token = ++requestToken
loading.value = true
try {
const data = await fetchEntityLogs(entityType.value, entityId.value, targetPage, PAGE_SIZE)
if (token !== requestToken) return
const items = data.member ?? []
entries.value = append ? [...entries.value, ...items] : items
totalItems.value = data.totalItems ?? entries.value.length
page.value = targetPage
} catch {
if (token !== requestToken) return
// Erreur silencieuse (timeline secondaire) — useApi n'affiche pas de toast avec toast: false.
entries.value = append ? entries.value : []
} finally {
if (token === requestToken) {
loading.value = false
}
}
}

  1. URL persistencereadQuery / syncQuery / route.query entierement supprimes de audit-log.vue.

  2. MalioDataTable → la liste admin passe par <MalioDataTable> avec slots #header-* et #cell-*. Plus de pagination hand-rolled.

  3. Composants Malio* → MalioInputText pour les filtres texte, MalioSelectCheckbox pour le multi-select entity_type (alimente par nouveau endpoint /api/audit-log-entity-types), MalioButton / MalioButtonIcon pour les actions. datetime-local conserve avec TODO pointant l'exception CLAUDE.md.

<template>
<div>
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
{{ t('admin.auditLog.title') }}
</h1>
</div>
<!-- Filtres -->
<section class="mt-4 rounded border border-gray-200 bg-white p-4">
<!-- Labels uniformes au-dessus : les composants Malio sont utilises sans
leur `label` flottant interne pour ne pas mixer deux patterns de label. -->
<div class="grid grid-cols-1 items-start gap-3 md:grid-cols-5">
<!-- TODO(malio-ui): remplacer par un composant Malio quand la lib
exposera un datetime picker. Cf. exception documentee dans
CLAUDE.md (section "Composants formulaires"). -->
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.date_from') }}
</label>
<input
v-model="filters.performedAtAfter"
type="datetime-local"
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
>
</div>
<!-- TODO(malio-ui): idem ci-dessus. -->
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.date_to') }}
</label>
<input
v-model="filters.performedAtBefore"
type="datetime-local"
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.entity_type') }}
</label>
<div class="[&>div>div]:!mt-0">
<MalioSelectCheckbox
v-model="selectedEntityTypes"
:options="entityTypeOptions"
:display-select-all="true"
:display-tag="true"
min-width="w-full"
text-field="text-sm"
text-value="text-sm"
/>
</div>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.user') }}
</label>
<MalioInputText
v-model="performedByInput"
icon-name="mdi:account-search"
input-class="text-sm"
group-class="h-10"
/>
</div>
<!-- TODO(malio-ui): remplacer par MalioSelect quand la lib
supportera de maniere fiable des options a valeur string
(cf. note Lesstime CLAUDE.md). Exception documentee dans
CLAUDE.md (section "Composants formulaires"). -->
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.action') }}
</label>
<select
v-model="actionValue"
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
>
<option value="">{{ t('audit.filters.all_actions') }}</option>
<option v-for="opt in actionOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
</div>
<div class="mt-3 flex justify-end">
<MalioButton
variant="tertiary"
:label="t('audit.filters.reset')"
button-class="text-xs"
@click="resetFilters"
/>
</div>
</section>
<!-- Tableau -->
<MalioDataTable
class="mt-4"
:columns="columns"
:items="rows"
:total-items="totalItems"
:page="filters.page ?? 1"
:per-page="filters.itemsPerPage ?? 10"
:per-page-options="[10, 25, 50]"
:empty-message="isFiltered ? t('audit.no_results') : t('audit.empty')"
@update:page="onPageChange"
@update:per-page="onPerPageChange"
@row-click="onRowClick"
>
<template #cell-action="{ item }">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
:class="actionBadgeClass(item.action as string)"
>
{{ t(`audit.action.${item.action}`) }}
</span>
</template>
<template #cell-entityType="{ item }">
<span class="font-mono text-xs">{{ item.entityType }}</span>
</template>
<template #cell-entityId="{ item }">

Bonus (hors review) : couverture ManyToMany (getScheduledCollectionUpdates/Deletions) avec 3 tests de regression, filtre entity_type[] multi-valeurs cote API, resetAuditLog() cable dans logout.vue.

Conventions concernees par les findings 3-5 ajoutees a CLAUDE.md dans le commit a95bb6c.

### Fixes appliquees Commits [a95bb6c](https://gitea.malio.fr/MALIO-DEV/Coltura/commit/a95bb6c) + [1505e84](https://gitea.malio.fr/MALIO-DEV/Coltura/commit/1505e84) — 228/228 tests verts. 1. **Proxy bypass AuditListener** → resolution via `$em->getClassMetadata($entity::class)->getName()` appliquee aux deux sites (`capturePendingLog` + `captureCollectionChange`). Regression test `testLogsUpdateOnProxyEntity` via `getReference()`. https://gitea.malio.fr/MALIO-DEV/Coltura/src/commit/1505e8492616bfec65f799930e6917d82669615d/src/Module/Core/Infrastructure/Doctrine/AuditListener.php#L173-L186 2. **AuditTimeline items 11-30** → `PAGE_SIZE = 10` cote composant, `itemsPerPage=10` passe au backend via `fetchEntityLogs(entityType, entityId, page, itemsPerPage)`. Token anti-race en bonus. https://gitea.malio.fr/MALIO-DEV/Coltura/src/commit/1505e8492616bfec65f799930e6917d82669615d/frontend/shared/components/audit/AuditTimeline.vue#L113-L150 3. **URL persistence** → `readQuery` / `syncQuery` / `route.query` entierement supprimes de `audit-log.vue`. 4. **MalioDataTable** → la liste admin passe par `<MalioDataTable>` avec slots `#header-*` et `#cell-*`. Plus de pagination hand-rolled. 5. **Composants Malio*** → `MalioInputText` pour les filtres texte, `MalioSelectCheckbox` pour le multi-select `entity_type` (alimente par nouveau endpoint `/api/audit-log-entity-types`), `MalioButton` / `MalioButtonIcon` pour les actions. `datetime-local` conserve avec TODO pointant l'exception CLAUDE.md. https://gitea.malio.fr/MALIO-DEV/Coltura/src/commit/1505e8492616bfec65f799930e6917d82669615d/frontend/modules/core/pages/admin/audit-log.vue#L1-L120 **Bonus** (hors review) : couverture ManyToMany (`getScheduledCollectionUpdates/Deletions`) avec 3 tests de regression, filtre `entity_type[]` multi-valeurs cote API, `resetAuditLog()` cable dans `logout.vue`. Conventions concernees par les findings 3-5 ajoutees a CLAUDE.md dans le commit [a95bb6c](https://gitea.malio.fr/MALIO-DEV/Coltura/commit/a95bb6c).
matthieu added 3 commits 2026-04-22 09:22:36 +00:00
API Platform 4 active swagger_ui/re_doc/scalar uniquement si TwigBundle
est present (les UI de docs sont rendues via Twig). Sans lui les flags
tombaient a false et /api/docs renvoyait 404 "Swagger UI, ReDoc and
Scalar are disabled." sur Accept: text/html.
Le drawer RBAC de /admin/users initialisait l'etat des sites a partir du payload
/api/users (groupe user:list) qui n'expose pas la collection sites. Consequence :
la section "Sites autorises" affichait toujours 0 case cochee, et la sauvegarde
ecrasait silencieusement les sites existants en BDD.

- Ajout d'une operation GET /users/{id}/rbac (groupe user:rbac:read) dediee au
  chargement du detail pour l'edition : payload list reste leger, detail riche
  sur une URI symetrique au PATCH existant.
- Drawer charge desormais GET /users/{id}/rbac pour initialiser sites, roles
  et directPermissions ; UserListItem ne contient plus sites (inutilise).
- Colonne "Sites" retiree de la table /admin/users : l'info est consultee via
  le drawer, pas la liste (evite aussi la fuite cross-site pour les users avec
  core.users.view mais sans sites.bypass_scope).
- Garde anti-ecrasement dans UserRbacProcessor : respect de la semantique
  merge-patch+json (cle absente = preservee, cle = [] = vidage explicite).
  Restaure les collections ManyToMany absentes du payload a partir du snapshot
  Doctrine. Couvre roles, directPermissions et sites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ajoute les sections "Evolutions post-livraison" aux specs Sites #02 et RBAC #345
pour refleter les modifs apportees apres la livraison initiale :

- GET /users/{id}/rbac symetrique au PATCH, pour charger le detail d'edition
  sans elargir le groupe user:list (le payload de liste reste leger, la
  dependance Core → Sites reste scopee a cet endpoint et a /api/me).
- Garde restoreAbsentCollections() dans UserRbacProcessor qui respecte la
  semantique merge-patch+json : cle absente = preservee, cle = [] = videe,
  cle = [...] = remplacee. Restauration a partir du snapshot Doctrine des
  PersistentCollection pour roles / directPermissions / sites.
- Nouveaux criteres de validation + matrice de semantique.

Verification archi modular monolith : Commercial et Sites peuvent etre
desactives dans config/modules.php sans casser l'app (sidebar filtree,
switcher masque, endpoints admin rediriges via disabledRoutes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
matthieu added 1 commit 2026-04-22 09:29:31 +00:00
- Section "Général" renommée en "Administration" (label i18n sidebar.administration.section).
- Item "Administration" (/admin) retiré : la route n'existait pas cote front, generait un 404 Nuxt silencieux a chaque clic.
- "Deconnexion" sortie de la section admin, deplacee dans une nouvelle section "Mon compte" (sidebar.account.section) sans permission RBAC — accessible a tout user authentifie.
- SidebarProvider supporte desormais un champ `permission` au niveau section : umbrella gate qui masque toute la section et bascule toutes ses routes dans disabledRoutes. Voir doc inline dans config/sidebar.php pour le pattern d'usage.

Avantage : pour gater toute l'administration derriere une permission coarse (ex: 'core.admin.access' future), ajouter 'permission' => 'core.admin.access' sur la section suffit — pas besoin de dupliquer la permission sur chaque item.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
matthieu added 2 commits 2026-04-22 13:54:58 +00:00
- Dashboard ("/") sort de la section Administration et rejoint "Mon compte" (accessible a tout user authentifie, comme Deconnexion).
- Cle i18n migree : sidebar.general.dashboard -> sidebar.account.dashboard. Le namespace "general" devenu vide est supprime.
- Documentation inline dans config/sidebar.php : formalise la convention "etre admin = detenir au moins une permission admin-scoped (core.* ou equivalent sites.view)". Le gate implicite via filtrage d'items rend inutile un gate section-level tant que chaque item porte sa permission.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
La table audit_log n'a pas d'entite ORM (ecriture DBAL brut via
AuditLogWriter pour eviter la recursion du listener). doctrine:schema:update
la considerait donc comme orpheline et la droppait systematiquement, ce qui
cassait la base de test apres chaque make test-db-setup (DROP TABLE audit_log
genere par schema:update --force, apres les migrations qui l'avaient creee).

Un schema_filter en negative lookahead sur la connexion default exclut la
table de toute comparaison de schema (schema:update, schema:validate, diff
de migrations). La creation / suppression reste pilotee exclusivement par
la migration Version20260420202749.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
matthieu added 2 commits 2026-04-22 15:33:56 +00:00
- TIMESTAMP(6) WITH TIME ZONE + tie-breaker id DESC sur l'ORDER BY pour
  garantir un tri deterministe quand plusieurs lignes partagent la meme
  timestamp (batch fixture, bulk flush < 1µs).
- Suppression de la clause ESCAPE '\\' redondante (`\` est deja
  l'echappement LIKE par defaut en PostgreSQL) et fragile sur
  standard_conforming_strings. Le str_replace des wildcards reste.
- paginationMaximumItemsPerPage : 100 -> 50. Reduit le pire cas de
  reponse lourde sur un endpoint admin (changes JSONB volumineux).
- 11 tests couvrant le login (3) et la visibilite sidebar par RBAC (8)
- 6 personas seedes via la commande app:seed-e2e, miroir cote front
  dans frontend/tests/e2e/_fixtures/personas.ts
- Page Objects (LoginPage, SidebarComponent) avec selecteurs stables
  par href + loginAs programmatique via cookie BEARER
- Targets Makefile : seed-e2e, test-e2e, test-e2e-ui, install-e2e-deps
- CLAUDE.md + README.md : workflow E2E + regle d'or "un E2E par bug
  prod uniquement" pour garder la suite maintenable dans la duree

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
matthieu added 1 commit 2026-04-23 06:37:00 +00:00
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
matthieu added 1 commit 2026-04-23 06:48:09 +00:00
matthieu added 1 commit 2026-04-23 06:55:50 +00:00
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
matthieu added 1 commit 2026-04-23 07:30:16 +00:00
- Permission entity : remplace le guard `ROLE_USER` par `core.permissions.view`
  sur GetCollection/Get. Le catalogue complet des permissions RBAC etait
  accessible a tout utilisateur authentifie. Ajoute la permission manquante
  dans CoreModule::permissions() et inverse les tests standardUser*
  (attendent maintenant un 403 pour un user sans la permission).

- UserRbacProcessor::restoreAbsentCollections() : force
  PersistentCollection::initialize() avant de lire le snapshot. Pour une
  association fetch=LAZY (ex: User::$sites), le snapshot est vide tant que
  la collection n'est pas materialisee, ce qui faisait vider silencieusement
  tous les sites d'un user sur un PATCH ne contenant pas la cle `sites`.

- admin/audit-log.vue : ajoute un catch sur loadEntries() qui reset
  entries/totalItems pour ne pas afficher de donnees stale si le fetch echoue
  (reseau coupe, 403 inopinee...). Le toast d'erreur reste gere par useApi.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
matthieu added 1 commit 2026-04-23 08:11:16 +00:00
Issues remontees par la seconde passe de review de la PR #9 :

- Regression `GET /api/permissions` 403 silencieux sur les drawers RBAC
  (UserRbacDrawer, RoleDrawer) apres le fix precedent qui imposait
  `core.permissions.view`. Les users porteurs de `core.users.manage` /
  `core.roles.manage` ne voyaient plus le catalogue pour hydrater leurs
  checkboxes. Elargit la security expression sur Permission en OR avec
  ces deux codes : les gestionnaires ont par nature besoin du catalogue
  (codes/libelles seuls, pas de secret expose).

- Race condition dans UserRbacProcessor : `restoreAbsentCollections()`
  lisait le snapshot Doctrine hors transaction, puis `wrapInTransaction()`
  flushait plus tard. Fenetre courte mais reelle ou une modification
  concurrente aurait pu etre annulee par une restauration depuis un
  snapshot stale. Deplace l'appel a l'interieur de la transaction.

- Stale-data sur les pages admin users / roles / sites : meme pattern
  try/finally sans catch que sur audit-log (deja corrige). Aligne les
  trois pages avec un catch qui reset la liste locale.

- Tests manquants : garde de non-regression sur PATCH /rbac sans `sites`
  (assure que la collection elle-meme est preservee, pas seulement le
  currentSite). Couverture positive sur GET /api/permissions pour les
  trois branches OR de la security expression (permissions.view,
  users.manage, roles.manage) via des users non-admin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
matthieu added 1 commit 2026-04-23 08:32:16 +00:00
Backend :
- AuditLogWriter::stripSensitive rendu reellement recursif (matche doc).
- Tests GET /api/permissions/{id} non-admin pour chaque branche OR (gap Codex).
- Gardes non-regression UserRbacProcessor : PATCH /rbac sans clef sites ne
  doit ni auto-selectionner currentSite ni exiger sites.manage.

Frontend :
- useAuditLog : renomme export trompeur fetchLogs -> fetchLogsCached, le
  nom reflete desormais le comportement (cache pollue sinon).
- RoleDrawer / UserRbacDrawer : catch explicite + message d'erreur +
  bouton save disabled si le chargement des referentiels a echoue (evite
  un ecrasement silencieux des droits).
- AuditTimeline / AuditLogDetail : `oui`/`non` passent par common.yes/no.
- AuditTimeline : Intl.RelativeTimeFormat et toLocaleString suivent la
  locale i18n courante (plus de hardcode 'fr').

E2E :
- sidebar-visibility.spec : remplace waitForLoadState('networkidle')
  fragile par attente semantique sur accountDashboardLink (stable en CI).

Tests : 237/237 green, eslint clean, php-cs-fixer clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
matthieu added 1 commit 2026-04-23 09:03:28 +00:00
- CLAUDE.md devient un index concis : contexte, stack, regles absolues
  numerotees, pointeurs vers les fichiers de regles detaillees via
  references @.claude/rules/*.md
- Les conventions detaillees (architecture, backend, frontend, testing,
  naming, git, workflow) sont extraites dans .claude/rules/ pour rester
  chargees a la demande sans gonfler le context du CLAUDE.md principal
- Ajoute la regle absolue "Ne jamais mentionner Claude/IA dans commits
  ou PR" (point 10) pour garder l'historique git signe par l'utilisateur
matthieu added 6 commits 2026-04-23 09:49:34 +00:00
T-003 — Ticket review 4e passe.
T-012 — aligne ces deux classes sur la convention de la PR (tous les
autres providers/processors sont final). Empeche une sous-classe de
contourner la logique de hachage ou l'auth Me par heritage accidentel.
T-010 — remplace le <button> HTML brut par MalioButton variant=tertiary,
conformement a la regle projet (tous les boutons passent par Malio*).

T-014 — etend relativeDate avec les paliers 'month' (~30.44j) et 'year'
(~365.25j). Avant, une entree vieille d'un an affichait "il y a 52
semaines" au lieu de "l'an dernier" (RelativeTimeFormat FR).
T-013 — sort la fonction debounce inline de audit-log.vue vers
frontend/shared/utils/debounce.ts (auto-importe par Nuxt) et ajoute
3 tests Vitest (delay coalesce, derniere invocation gagne, plusieurs
executions espacees). Pret pour reutilisation sur les prochaines
pages avec recherche/filtres.
T-011 — deplace la cle sidebar.core.sites sous son module owner
(sidebar.sites.admin). Aligne sur la convention naming.md : les cles
sidebar doivent vivre sous le namespace du module qui expose l'item.

T-015 — traduit entityType dans la page d'audit via des cles i18n
audit.entity.core_user / core_role / core_permission / sites_site.
Helper formatEntityType avec fallback sur l'identifiant brut pour
rester debug-friendly si une traduction manque. Applique sur :
- la cellule du tableau (tooltip garde l'identifiant technique)
- les options du filtre multi-select MalioSelectCheckbox
- le titre du drawer de detail + h3 interne
matthieu added 1 commit 2026-04-23 09:50:33 +00:00
matthieu added 11 commits 2026-05-02 15:23:13 +00:00
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>
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>
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>
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>
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>
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>
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>
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.
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.
- 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.
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.
matthieu added 1 commit 2026-05-13 08:28:25 +00:00
matthieu merged commit e6c8381b3c into develop 2026-05-13 08:29:31 +00:00
matthieu deleted branch feat/audit-log 2026-05-13 08:29:31 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: MALIO-DEV/Coltura#9