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>
425 lines
17 KiB
Markdown
425 lines
17 KiB
Markdown
# 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** :
|
|
```php
|
|
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` :
|
|
|
|
```typescript
|
|
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`) :
|
|
|
|
```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)
|