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