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>
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 entiteidx_audit_performer:(performed_by, performed_at)— recherche par utilisateuridx_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,secretne doivent jamais apparaitre danschanges performed_bydenormalise : string, pas FK — le nom persiste meme si l'utilisateur est supprime- Migration : dans
migrations/(namespace racineDoctrineMigrations) 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_idqui 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 dedieeaudit(meme DSN, service separe) pour eviter l'entanglement transactionnel avec l'ORM. Config :doctrine.dbal.connections.auditdansdoctrine.yaml. Injection via#[Autowire(service: 'doctrine.dbal.audit_connection')].Symfony\Bundle\SecurityBundle\SecuritySymfony\Component\HttpFoundation\RequestStackRequestIdProvider
Methode principale :
public function log(
string $entityType,
string $entityId,
string $action,
array $changes,
): void
Comportement :
- Genere
idviaUuid::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.requestvia#[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 viaAuditLogWriter(hors transaction Doctrine)
Dependances :
AuditLogWriterLoggerInterface
Logique onFlush :
- Recupere
UnitOfWork - Parcourt insertions, updates, deletions
- Pour chaque entite : verifie
#[Auditable]viaReflectionClass::getAttributes() - Filtre les proprietes
#[AuditIgnore]+ blacklist hardcodee - Formate les changements :
- create : snapshot complet de toutes les proprietes non-ignorees
- update :
{champ: {old: x, new: y}}viagetEntityChangeSet() - delete : snapshot complet
- ManyToOne : log l'ID via
?->getId()(null-safe pour les relations nullable), pas l'objet - Stocke dans
$pendingLogs(propriete privee)
Logique postFlush — pattern swap-and-clear (protection contre flush re-entrant) :
- Copie
$pendingLogsdans variable locale, vide immediatement$this->pendingLogs = [] - Pour chaque log copie →
AuditLogWriter::log() - 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 viagetScheduledCollectionUpdates().
API Platform — Lecture seule
AuditLogResource
Emplacement : src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php
Operations :
GET /api/audit-logs— collection paginee (30 items/page), triperformed_at DESCGET /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 exactentity_id: filtre exactaction: filtre exactperformed_by: filtre exactperformed_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
- create :
- 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: stringentityId: 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 :
AuditLogWriterutilise une connexion separeeaudit(meme DSN) pour eviter l'entanglement transactionnel avec l'ORM en batch - PaginatorInterface : le provider retourne un
DbalPaginatorimplementant l'interface API Platform — pas de construction manuellehydra:view - Type natif
uuidPG : 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_typeformatmodule.Entity: evite collisions si deux modules ont des entites de meme nom
Dependances externes
symfony/uid: generation UUID v7 (id) et v4 (request_id)