Files
Coltura/doc/audit-log.md
matthieu de39fe6a3e feat : add audit log (table, writer, listener, API, admin UI, timeline)
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.
2026-04-20 20:51:10 +02:00

15 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

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 : non couvert par getEntityChangeSet() — limitation connue. Les changements de collections (ex: User::$rbacRoles) ne sont pas audites. Ajout futur possible via getScheduledCollectionUpdates().

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]
  • ManyToMany non audite : limitation connue, getEntityChangeSet() ne couvre pas les collections
  • 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)