Files
Coltura/doc/audit-log.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

17 KiB

Audit Log — Specification technique

Objectif

Tracer l'historique de toutes les modifications BDD dans une table audit_log append-only. L'audit est opt-in via l'attribut #[Auditable] sur les entites, expose en lecture seule via API Platform (permission RBAC core.audit_log.view), et visualise dans le frontend via une page dediee et un composant timeline reutilisable.

Regle projet : toute entite (nouvelle ou existante) doit etre annotee #[Auditable] avec #[AuditIgnore] sur les champs sensibles. L'audit n'est pas optionnel — il est obligatoire sur toutes les entites metier.


Architecture

src/
  Shared/
    Domain/
      Attribute/
        Auditable.php              # Attribut classe — active le tracking
        AuditIgnore.php            # Attribut propriete — exclut un champ
  Module/
    Core/
      CoreModule.php               # + permission core.audit_log.view
      Application/
        DTO/
          AuditLogOutput.php       # DTO lecture seule
      Infrastructure/
        Audit/
          AuditLogWriter.php       # Ecrit via DBAL (pas Doctrine ORM)
          RequestIdProvider.php    # UUID v4 par requete HTTP
        Doctrine/
          AuditListener.php        # Listener onFlush/postFlush
          Migrations/              # (migration dans migrations/ racine — cf. bug tri FQCN)
        ApiPlatform/
          Resource/
            AuditLogResource.php   # ApiResource read-only
          State/
            Provider/
              AuditLogProvider.php  # Provider DBAL

frontend/
  shared/
    composables/
      useAuditLog.ts               # Composable partage (page + timeline)
    components/
      audit/
        AuditTimeline.vue          # Timeline verticale reutilisable
    types/
      index.ts                     # + AuditLogEntry, AuditLogFilters, HydraView
    utils/
      api.ts                       # + support hydra:view pagination
  modules/
    core/
      pages/
        admin/
          audit-log.vue            # Page globale admin

Table PostgreSQL audit_log

Table non geree par Doctrine ORM (pas d'entite). Ecriture via DBAL uniquement pour eviter la recursion des listeners.

Schema

Colonne Type Contrainte Description
id uuid PK UUID v7 genere en PHP (Uuid::v7()->toRfc4122()) — type natif PG (16 octets vs 36 en varchar)
entity_type varchar(100) NOT NULL Format module.Entity (ex: core.User, commercial.Client) — evite les collisions inter-modules
entity_id varchar(64) NOT NULL ID de l'entite (supporte int et UUID)
action varchar(10) NOT NULL create, update, delete
changes jsonb NOT NULL DEFAULT '{}' Changements (format selon action)
performed_by varchar(100) NOT NULL Username denormalise (survit a la suppression du user)
performed_at timestamptz NOT NULL Horodatage de l'action
ip_address varchar(45) NULL Adresse IP (null en CLI)
request_id varchar(36) NULL UUID v4 par requete HTTP (null en CLI)

Index

  • idx_audit_entity_time : (entity_type, entity_id, performed_at) — recherche par entite
  • idx_audit_performer : (performed_by, performed_at) — recherche par utilisateur
  • idx_audit_time : (performed_at) — tri chronologique global

Regles

  • Append-only : pas d'UPDATE, pas de DELETE
  • Colonnes en minuscules (convention PostgreSQL du projet)
  • Champs sensibles exclus : password, plainPassword, token, secret ne doivent jamais apparaitre dans changes
  • performed_by denormalise : string, pas FK — le nom persiste meme si l'utilisateur est supprime
  • Migration : dans migrations/ (namespace racine DoctrineMigrations) a cause du bug de tri alphabetique FQCN de Doctrine Migrations 3.x entre namespaces

Contrat : ce que audit_log garantit (et ne garantit pas)

audit_log enregistre les tentatives de modification capturees par le postFlush Doctrine, ecrites via une connexion DBAL dediee (audit_connection). Ce choix est intentionnel : les lignes d'audit survivent au rollback eventuel de la transaction metier principale, ce qui permet de tracer les tentatives meme en cas d'echec applicatif.

Conséquence à connaître : si un controller enveloppe plusieurs operations dans une transaction explicite sur la connexion default et que cette transaction outermost rollback apres un flush intermediaire reussi, la ligne audit correspondante persiste sur la connexion audit alors que la modification metier a ete annulee. L'audit log peut donc contenir des lignes decrivant un etat qui n'existe pas en base metier.

En pratique :

  • Ce cas est rare dans un CRM interne (les rollbacks explicites outermost sont marginaux par rapport aux flushes atomiques).
  • La ligne audit garde son request_id qui permet une correlation post-mortem avec les logs applicatifs pour distinguer une tentative avortee d'un commit reussi.
  • Le comportement est volontaire — pas un bug. Pour un besoin de garantie « audit = reflet exact du commit outermost », il faudrait basculer l'audit sur la meme connexion que le metier (voir AuditLogWriter), au prix de perdre la resilience au rollback partiel.

L'audit est donc un journal des intentions appliquees par l'ORM, pas une source de verite transactionnelle sur l'etat final de la DB.


Composants backend

AuditLogWriter

Emplacement : src/Module/Core/Infrastructure/Audit/AuditLogWriter.php

Service responsable de l'ecriture dans audit_log via Connection::executeStatement().

Dependances :

  • Doctrine\DBAL\Connection — connexion DBAL dediee audit (meme DSN, service separe) pour eviter l'entanglement transactionnel avec l'ORM. Config : doctrine.dbal.connections.audit dans doctrine.yaml. Injection via #[Autowire(service: 'doctrine.dbal.audit_connection')].
  • Symfony\Bundle\SecurityBundle\Security
  • Symfony\Component\HttpFoundation\RequestStack
  • RequestIdProvider

Methode principale :

public function log(
    string $entityType,
    string $entityId,
    string $action,
    array $changes,
): void

Comportement :

  • Genere id via Uuid::v7()->toRfc4122()
  • performed_by = $security->getUser()?->getUserIdentifier() ?? 'system'
  • ip_address = $requestStack->getCurrentRequest()?->getClientIp()
  • request_id = $requestIdProvider->getRequestId()
  • performed_at = new \DateTimeImmutable('now', new \DateTimeZone('UTC'))
  • Filtre les cles sensibles (password, plainPassword, token, secret) de $changes
  • INSERT SQL brut via DBAL

Necessite : composer require symfony/uid

RequestIdProvider

Emplacement : src/Module/Core/Infrastructure/Audit/RequestIdProvider.php

Service singleton qui genere un UUID v4 unique par requete HTTP principale.

Comportement :

  • Ecoute kernel.request via #[AsEventListener]
  • Ignore les sub-requests : if (!$event->isMainRequest()) return;
  • Genere Uuid::v4()->toRfc4122() a chaque requete principale
  • Expose getRequestId(): ?string (null en CLI)

Attributs #[Auditable] et #[AuditIgnore]

Emplacement : src/Shared/Domain/Attribute/ (dans Shared, pas Core — tous les modules doivent y acceder)

  • #[Auditable] : attribut de classe, marqueur vide. Active le tracking sur l'entite.
  • #[AuditIgnore] : attribut de propriete, marqueur vide. Exclut un champ du tracking.

AuditListener

Emplacement : src/Module/Core/Infrastructure/Doctrine/AuditListener.php

Listener Doctrine (pas EventSubscriber — deprecie Symfony 8) utilisant #[AsDoctrineListener].

Evenements :

  • onFlush : collecte les changesets (aucune ecriture)
  • postFlush : ecrit via AuditLogWriter (hors transaction Doctrine)

Dependances :

  • AuditLogWriter
  • LoggerInterface

Logique onFlush :

  1. Recupere UnitOfWork
  2. Parcourt insertions, updates, deletions
  3. Pour chaque entite : verifie #[Auditable] via ReflectionClass::getAttributes()
  4. Filtre les proprietes #[AuditIgnore] + blacklist hardcodee
  5. Formate les changements :
    • create : snapshot complet de toutes les proprietes non-ignorees
    • update : {champ: {old: x, new: y}} via getEntityChangeSet()
    • delete : snapshot complet
  6. ManyToOne : log l'ID via ?->getId() (null-safe pour les relations nullable), pas l'objet
  7. Stocke dans $pendingLogs (propriete privee)

Logique postFlush — pattern swap-and-clear (protection contre flush re-entrant) :

  1. Copie $pendingLogs dans variable locale, vide immediatement $this->pendingLogs = []
  2. Pour chaque log copie → AuditLogWriter::log()
  3. Try/catch : erreur → $logger->error(...), jamais de crash

Cas particuliers :

  • Flush sans changement → rien
  • Entite sans #[Auditable] → ignoree
  • Batch (fixtures, import) → chaque entite auditee, groupees par request_id
  • Console → performed_by = 'system', ip_address = null, request_id = null
  • ManyToMany / OneToMany : tracees via UnitOfWork::getScheduledCollectionUpdates() et getScheduledCollectionDeletions() (cf. AuditListener::captureCollectionChange). Payload {fieldName: {added: [ids], removed: [ids]}}, merge dans le log deja en attente de l'entite proprietaire si elle est aussi scheduled (insertion → snapshot enrichi, update → diff merge, delete → ignore car redondant avec le snapshot delete).

API Platform — Lecture seule

AuditLogResource

Emplacement : src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php

Operations :

  • GET /api/audit-logs — collection paginee (30 items/page), tri performed_at DESC
  • GET /api/audit-logs/{id} — detail

Securite : is_granted('core.audit_log.view') — permission RBAC, 403 sinon

Pas d'endpoints d'ecriture : POST, PUT, PATCH, DELETE → 405

AuditLogOutput

Emplacement : src/Module/Core/Application/DTO/AuditLogOutput.php

DTO readonly avec les champs : id, entityType, entityId, action, changes, performedBy, performedAt, ipAddress, requestId.

AuditLogProvider

Emplacement : src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php

Provider DBAL (pas Doctrine ORM).

Filtres (query params, combinables en AND) :

  • entity_type : filtre exact
  • entity_id : filtre exact
  • action : filtre exact
  • performed_by : filtre exact
  • performed_at[after] : date minimum (incluse)
  • performed_at[before] : date maximum (incluse)

Pagination : via un DbalPaginator implementant ApiPlatform\State\Pagination\PaginatorInterface (getCurrentPage(), getLastPage(), getTotalItems(), getItemsPerPage(), count(), getIterator()). Le provider retourne ce paginator — API Platform genere automatiquement hydra:view. Pas de construction manuelle de la pagination.

Permission RBAC

Ajouter dans CoreModule::permissions() :

  • core.audit_log.view

Frontend

Composable useAuditLog.ts

Emplacement : frontend/shared/composables/useAuditLog.ts

Composable partage, reutilise par la page globale (ticket 4) et le composant timeline (ticket 5).

Methodes :

  • fetchLogs(filters?: AuditLogFilters): Promise<HydraCollection<AuditLogEntry>>
  • fetchLogById(id: string): Promise<AuditLogEntry>
  • fetchEntityLogs(entityType: string, entityId: string, page?: number): Promise<HydraCollection<AuditLogEntry>>

Utilise useApi().get().

Si le composable maintient du state singleton (refs module-level pour cache), il doit exposer resetAuditLog() et etre reinitialise au logout (regle CLAUDE.md).

Types

Ajouter dans frontend/shared/types/index.ts :

export interface AuditLogEntry {
    id: string
    entityType: string
    entityId: string
    action: 'create' | 'update' | 'delete'
    changes: Record<string, unknown>
    performedBy: string
    performedAt: string
    ipAddress: string | null
    requestId: string | null
}

export interface AuditLogFilters {
    entityType?: string
    entityId?: string
    action?: string
    performedBy?: string
    performedAtAfter?: string
    performedAtBefore?: string
    page?: number
}

interface HydraView {
    'hydra:first'?: string
    'hydra:last'?: string
    'hydra:next'?: string
    'hydra:previous'?: string
}

Le type HydraView doit etre ajoute dans frontend/shared/utils/api.ts (a cote de HydraCollection) et HydraCollection doit etre etendu avec un champ optionnel 'hydra:view'?: HydraView.

Page admin/audit-log.vue

Emplacement : frontend/modules/core/pages/admin/audit-log.vue

Acces : permission RBAC core.audit_log.view (verifie via usePermissions().can('core.audit_log.view'))

Elements :

  • Tableau pagine avec style projet (header bg-tertiary-500, rows hover)
  • Filtres : plage dates, type entite (select), utilisateur (input), action (checkboxes), bouton reset
  • Filtres persistes dans les query params URL
  • Ligne expandable au clic :
    • update : tableau champ / ancienne valeur / nouvelle valeur
    • create/delete : snapshot complet
  • Badges action :
    • create : bg-green-100 text-green-800
    • update : bg-yellow-100 text-yellow-800
    • delete : bg-red-100 text-red-800
  • Pagination prev/next via hydra:view
  • Etat vide : message i18n "Aucune activite enregistree"
  • Chargement initial : 30 dernieres entrees sans filtre

Sidebar

Ajouter entree dans config/sidebar.php :

  • Label : sidebar.core.audit_log
  • Route : /admin/audit-log
  • Icon : a definir (ex: mdi:clipboard-text-clock)
  • Module : core
  • Permission : core.audit_log.view — filtre automatiquement cote SidebarProvider

Composant AuditTimeline.vue

Emplacement : frontend/shared/components/audit/AuditTimeline.vue

Composant reutilisable, auto-importe par Nuxt.

Props :

  • entityType: string
  • entityId: string | number

Comportement :

  • Garde permission : si !usePermissions().can('core.audit_log.view') → rendu vide, aucun appel API
  • Timeline verticale : bordure gauche (border-l-2 border-gray-200) + dots colores par action
  • Chaque entree : icone + date relative FR (Intl.RelativeTimeFormat('fr')) + date absolue en tooltip + utilisateur + resume
  • Update : affiche old → new par champ
  • Lazy loading : 10 items initiaux + bouton "Voir plus"
  • Skeleton loader pendant le chargement
  • Etat vide : "Aucun historique"

Premiere integration : sur la page admin/audit-log.vue


i18n

Cles a ajouter dans frontend/i18n/locales/fr.json :

Structure imbriquee (respecte le format existant de fr.json) :

{
    "sidebar": {
        "core": {
            "audit_log": "Journal d'audit"
        }
    },
    "audit": {
        "action": {
            "create": "Création",
            "update": "Modification",
            "delete": "Suppression"
        },
        "entity": {
            "user": "Utilisateur"
        },
        "empty": "Aucune activité enregistrée",
        "no_results": "Aucun résultat pour ces filtres",
        "timeline": {
            "empty": "Aucun historique",
            "load_more": "Voir plus"
        },
        "filters": {
            "reset": "Réinitialiser",
            "date_from": "Du",
            "date_to": "Au",
            "entity_type": "Type d'entité",
            "user": "Utilisateur",
            "action": "Action"
        },
        "detail": {
            "field": "Champ",
            "old_value": "Ancienne valeur",
            "new_value": "Nouvelle valeur"
        }
    }
}

Ordre d'implementation

Ticket 1 ────► Ticket 2 ────► Ticket 3 ────┬──► Ticket 4
  Table +        Attributs +     API           │    Page admin
  Writer         Listener        read-only     │
                                               └──► Ticket 5
                                                    Timeline
                                            (4 et 5 en parallele)

Decisions techniques (issues reviews)

  • Connexion DBAL dediee : AuditLogWriter utilise une connexion separee audit (meme DSN) pour eviter l'entanglement transactionnel avec l'ORM en batch
  • PaginatorInterface : le provider retourne un DbalPaginator implementant l'interface API Platform — pas de construction manuelle hydra:view
  • Type natif uuid PG : 16 octets vs 36 en varchar, index 40% plus petit sur table append-only a croissance infinie
  • Pattern swap-and-clear dans postFlush : protection contre flush re-entrant
  • Blacklist exact-match sur noms de proprietes (password, plainPassword, token, secret) — en defense-in-depth avec #[AuditIgnore]
  • Collections to-many auditees : tracees via getScheduledCollectionUpdates / getScheduledCollectionDeletions, payload {added, removed} merge dans le changeset de l'entite proprietaire (cf. AuditListener::captureCollectionChange)
  • Erreur audit silencieuse : loguee, jamais propagee — pas de retry/dead-letter (acceptable pour CRM interne)
  • entity_type format module.Entity : evite collisions si deux modules ont des entites de meme nom

Dependances externes

  • symfony/uid : generation UUID v7 (id) et v4 (request_id)