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.
412 lines
15 KiB
Markdown
412 lines
15 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
|
|
|
|
---
|
|
|
|
## 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)
|