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.
This commit is contained in:
2026-04-20 20:51:10 +02:00
parent 140dca9061
commit de39fe6a3e
31 changed files with 2754 additions and 6 deletions

View File

@@ -234,6 +234,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour eviter le conflit avec API Platform `{id}`
- Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux proprietes de l'entite cible
- Upload fichiers : utiliser `$file->getMimeType()` (pas `getClientMimeType()`) pour valider cote serveur
- **Audit obligatoire** : toute entite (nouvelle ou existante) doit porter `#[Auditable]` (dans `Shared/Domain/Attribute/`). Les champs sensibles (password, token, secret) doivent etre annotes `#[AuditIgnore]`. Spec complete : `doc/audit-log.md`
### Frontend

View File

@@ -31,6 +31,7 @@
"symfony/runtime": "8.0.*",
"symfony/security-bundle": "8.0.*",
"symfony/serializer": "8.0.*",
"symfony/uid": "8.0.*",
"symfony/validator": "8.0.*",
"symfony/yaml": "8.0.*"
},

2
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "75f8e672f2a401290886fbcf01befd3f",
"content-hash": "65f8419b8830b250fe461933fe240a14",
"packages": [
{
"name": "api-platform/doctrine-common",

View File

@@ -9,6 +9,9 @@ api_platform:
mapping:
paths:
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
# Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource]
# en dehors de Domain/Entity : AuditLogResource, etc.
- '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource'
formats:
jsonld: ['application/ld+json']
json: ['application/json']

View File

@@ -1,7 +1,15 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
profiling_collect_backtrace: '%kernel.debug%'
# Deux connexions pointant sur le meme DSN : l'ORM utilise `default`,
# l'AuditLogWriter utilise `audit` pour ecrire hors de la transaction
# Doctrine et eviter tout entanglement transactionnel en batch.
default_connection: default
connections:
default:
url: '%env(resolve:DATABASE_URL)%'
profiling_collect_backtrace: '%kernel.debug%'
audit:
url: '%env(resolve:DATABASE_URL)%'
orm:
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
@@ -31,7 +39,16 @@ doctrine:
when@test:
doctrine:
dbal:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
# Le suffixe "_test" doit etre propage aux deux connexions : l'ORM
# l'herite via `default`, l'AuditLogWriter via `audit`. Sans cela,
# la connexion `audit` ecrirait dans la base dev pendant que l'ORM
# ecrit dans la base test — divergence invisible en apparence mais
# fatale pour les tests du journal d'audit.
connections:
default:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
audit:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
orm:
mappings:
# Entite fictive SiteAware utilisee uniquement en tests du

View File

@@ -467,7 +467,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* },
* disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true
* http_client?: bool|array{ // HTTP Client configuration
* enabled?: bool|Param, // Default: true
* enabled?: bool|Param, // Default: false
* max_host_connections?: int|Param, // The maximum number of connections to a single host.
* default_options?: array{
* headers?: array<string, mixed>,

View File

@@ -55,6 +55,13 @@ return [
'module' => 'sites',
'permission' => 'sites.view',
],
[
'label' => 'sidebar.core.audit_log',
'to' => '/admin/audit-log',
'icon' => 'mdi:clipboard-text-clock',
'module' => 'core',
'permission' => 'core.audit_log.view',
],
[
'label' => 'sidebar.general.logout',
'to' => '/logout',

411
doc/audit-log.md Normal file
View File

@@ -0,0 +1,411 @@
# 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)

View File

@@ -26,7 +26,8 @@
"core": {
"roles": "Gestion des rôles",
"users": "Utilisateurs",
"sites": "Sites"
"sites": "Sites",
"audit_log": "Journal d'audit"
}
},
"dashboard": {
@@ -66,6 +67,35 @@
"switchSuccess": "Site courant changé"
}
},
"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"
}
},
"success": {
"auth": {
"logout": "Deconnexion reussie"
@@ -132,6 +162,21 @@
"updated": "Permissions mises à jour avec succès"
}
},
"auditLog": {
"title": "Journal d'audit",
"table": {
"performedAt": "Date",
"performedBy": "Utilisateur",
"entityType": "Entité",
"entityId": "ID",
"action": "Action",
"summary": "Résumé"
},
"pagination": {
"previous": "Précédent",
"next": "Suivant"
}
},
"sites": {
"title": "Gestion des sites",
"newSite": "Nouveau site",

View File

@@ -0,0 +1,356 @@
<template>
<div>
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
{{ t('admin.auditLog.title') }}
</h1>
</div>
<!-- Filtres -->
<section class="mt-4 rounded border border-gray-200 bg-white p-4">
<div class="grid grid-cols-1 gap-3 md:grid-cols-5">
<div>
<label class="block text-xs font-medium text-gray-600">
{{ t('audit.filters.date_from') }}
</label>
<input
v-model="filters.performedAtAfter"
type="datetime-local"
class="mt-1 w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">
{{ t('audit.filters.date_to') }}
</label>
<input
v-model="filters.performedAtBefore"
type="datetime-local"
class="mt-1 w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">
{{ t('audit.filters.entity_type') }}
</label>
<input
v-model="filters.entityType"
type="text"
placeholder="core.User"
class="mt-1 w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">
{{ t('audit.filters.user') }}
</label>
<input
v-model="filters.performedBy"
type="text"
class="mt-1 w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">
{{ t('audit.filters.action') }}
</label>
<div class="mt-1 flex flex-wrap gap-2">
<label v-for="a in allActions" :key="a" class="flex items-center gap-1 text-xs">
<input
type="checkbox"
:checked="selectedActions.includes(a)"
@change="toggleAction(a)"
>
{{ t(`audit.action.${a}`) }}
</label>
</div>
</div>
</div>
<div class="mt-3 flex justify-end">
<button
type="button"
class="px-3 py-1 text-xs rounded border border-gray-300 hover:bg-gray-50"
@click="resetFilters"
>
{{ t('audit.filters.reset') }}
</button>
</div>
</section>
<!-- Tableau -->
<section class="mt-4 rounded border border-gray-200 bg-white overflow-hidden">
<table class="min-w-full text-sm">
<thead class="bg-tertiary-500 text-white">
<tr>
<th class="px-3 py-2 text-left font-medium">
{{ t('admin.auditLog.table.performedAt') }}
</th>
<th class="px-3 py-2 text-left font-medium">
{{ t('admin.auditLog.table.performedBy') }}
</th>
<th class="px-3 py-2 text-left font-medium">
{{ t('admin.auditLog.table.entityType') }}
</th>
<th class="px-3 py-2 text-left font-medium">
{{ t('admin.auditLog.table.entityId') }}
</th>
<th class="px-3 py-2 text-left font-medium">
{{ t('admin.auditLog.table.action') }}
</th>
<th class="px-3 py-2 text-left font-medium">
{{ t('admin.auditLog.table.summary') }}
</th>
</tr>
</thead>
<tbody>
<template v-if="entries.length > 0">
<template v-for="entry in entries" :key="entry.id">
<tr
class="border-t border-gray-100 hover:bg-gray-50 cursor-pointer"
@click="toggleExpand(entry.id)"
>
<td class="px-3 py-2">
{{ formatDate(entry.performedAt) }}
</td>
<td class="px-3 py-2">{{ entry.performedBy }}</td>
<td class="px-3 py-2 font-mono text-xs">{{ entry.entityType }}</td>
<td class="px-3 py-2 font-mono text-xs">{{ entry.entityId }}</td>
<td class="px-3 py-2">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
:class="actionBadgeClass(entry.action)"
>
{{ t(`audit.action.${entry.action}`) }}
</span>
</td>
<td class="px-3 py-2 text-xs text-gray-600">
{{ summarize(entry) }}
</td>
</tr>
<!-- Detail expandable : diff courant + timeline complete de l'entite. -->
<tr v-if="expandedId === entry.id" class="bg-gray-50">
<td colspan="6" class="px-3 py-3">
<AuditLogDetail :entry="entry" />
<div class="mt-4 border-t border-gray-200 pt-3">
<h3 class="text-sm font-medium text-gray-700 mb-2">
{{ entry.entityType }} #{{ entry.entityId }}
</h3>
<AuditTimeline
:entity-type="entry.entityType"
:entity-id="entry.entityId"
/>
</div>
</td>
</tr>
</template>
</template>
<tr v-else-if="!loading">
<td colspan="6" class="px-3 py-6 text-center text-sm text-gray-500">
{{ isFiltered ? t('audit.no_results') : t('audit.empty') }}
</td>
</tr>
<tr v-else>
<td colspan="6" class="px-3 py-6 text-center text-sm text-gray-500">
{{ t('common.loading') }}
</td>
</tr>
</tbody>
</table>
</section>
<!-- Pagination via hydra:view -->
<nav class="mt-3 flex items-center justify-between text-sm">
<span class="text-gray-600">
{{ totalItems }} entrée{{ totalItems > 1 ? 's' : '' }}
</span>
<div class="flex gap-2">
<button
type="button"
class="px-3 py-1 rounded border border-gray-300 disabled:opacity-60"
:disabled="!hasPrevious || loading"
@click="goPrevious"
>
{{ t('admin.auditLog.pagination.previous') }}
</button>
<button
type="button"
class="px-3 py-1 rounded border border-gray-300 disabled:opacity-60"
:disabled="!hasNext || loading"
@click="goNext"
>
{{ t('admin.auditLog.pagination.next') }}
</button>
</div>
</nav>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
const { t } = useI18n()
const { can } = usePermissions()
const router = useRouter()
const route = useRoute()
const { fetchLogs } = useAuditLog()
// Protection cote UI : le middleware `modules.global.ts` filtre deja les
// routes desactivees, mais si quelqu'un atterit ici sans la permission on
// renvoie sur la page admin parente plutot que de flasher un ecran vide.
if (!can('core.audit_log.view')) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
}
useHead({ title: t('admin.auditLog.title') })
const allActions = ['create', 'update', 'delete'] as const
type ActionKind = typeof allActions[number]
const filters = reactive<AuditLogFilters>({
performedAtAfter: readQuery('after'),
performedAtBefore: readQuery('before'),
entityType: readQuery('entity_type'),
performedBy: readQuery('performed_by'),
action: readQuery('action'),
page: Number(readQuery('page') ?? 1) || 1,
})
// Les checkboxes d'action fonctionnent en multi-select cote UI mais l'API
// ne supporte qu'une valeur a la fois : on combine les cases cochees en un
// seul filtre "action=X" lorsque une seule case est active. Si plusieurs ou
// zero sont cochees, on n'applique pas le filtre action (comportement =
// "toutes actions").
const selectedActions = ref<ActionKind[]>(filters.action ? [filters.action as ActionKind] : [])
const entries = ref<AuditLogEntry[]>([])
const totalItems = ref(0)
const hasPrevious = ref(false)
const hasNext = ref(false)
const loading = ref(false)
const expandedId = ref<string | null>(null)
const isFiltered = computed(() =>
Boolean(filters.performedAtAfter || filters.performedAtBefore || filters.entityType
|| filters.performedBy || filters.action),
)
function readQuery(key: string): string | undefined {
const v = route.query[key]
return typeof v === 'string' && v !== '' ? v : undefined
}
function toggleAction(action: ActionKind): void {
const idx = selectedActions.value.indexOf(action)
if (idx >= 0) selectedActions.value.splice(idx, 1)
else selectedActions.value.push(action)
filters.action = selectedActions.value.length === 1 ? selectedActions.value[0] : undefined
filters.page = 1
syncQuery()
}
function resetFilters(): void {
filters.performedAtAfter = undefined
filters.performedAtBefore = undefined
filters.entityType = undefined
filters.performedBy = undefined
filters.action = undefined
filters.page = 1
selectedActions.value = []
syncQuery()
}
async function loadEntries(): Promise<void> {
loading.value = true
try {
const data = await fetchLogs({
...filters,
// Convertit datetime-local (YYYY-MM-DDTHH:MM) en ISO pour l'API.
performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined,
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined,
})
entries.value = data['hydra:member'] ?? []
totalItems.value = data['hydra:totalItems'] ?? 0
const view = data['hydra:view']
hasPrevious.value = Boolean(view?.['hydra:previous'])
hasNext.value = Boolean(view?.['hydra:next'])
} finally {
loading.value = false
}
}
function toIso(localDateTime: string): string {
// datetime-local n'a pas de timezone : on assume heure locale et on
// laisse le navigateur generer l'ISO via Date().
return new Date(localDateTime).toISOString()
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('fr-FR', {
dateStyle: 'short',
timeStyle: 'short',
})
}
function actionBadgeClass(action: string): string {
switch (action) {
case 'create': return 'bg-green-100 text-green-800'
case 'update': return 'bg-yellow-100 text-yellow-800'
case 'delete': return 'bg-red-100 text-red-800'
default: return 'bg-gray-100 text-gray-800'
}
}
function summarize(entry: AuditLogEntry): string {
const keys = Object.keys(entry.changes)
if (keys.length === 0) return '—'
if (keys.length <= 3) return keys.join(', ')
return `${keys.slice(0, 3).join(', ')}… (+${keys.length - 3})`
}
function toggleExpand(id: string): void {
expandedId.value = expandedId.value === id ? null : id
}
function goPrevious(): void {
if (!hasPrevious.value || !filters.page) return
filters.page = Math.max(1, filters.page - 1)
syncQuery()
}
function goNext(): void {
if (!hasNext.value) return
filters.page = (filters.page ?? 1) + 1
syncQuery()
}
// Persiste les filtres dans les query params URL pour que le reload ou le
// partage de lien retrouve le meme etat.
function syncQuery(): void {
const query: Record<string, string> = {}
if (filters.performedAtAfter) query.after = filters.performedAtAfter
if (filters.performedAtBefore) query.before = filters.performedAtBefore
if (filters.entityType) query.entity_type = filters.entityType
if (filters.performedBy) query.performed_by = filters.performedBy
if (filters.action) query.action = filters.action
if (filters.page && filters.page !== 1) query.page = String(filters.page)
router.replace({ query })
}
// Synchronisation reactive : tout changement de filtre declenche un fetch
// + reset de la pagination a la page 1 (sauf si seul `page` a change).
watch(
() => [filters.performedAtAfter, filters.performedAtBefore, filters.entityType, filters.performedBy, filters.action],
() => {
filters.page = 1
syncQuery()
loadEntries()
},
)
watch(() => filters.page, () => { loadEntries() })
onMounted(() => {
loadEntries()
})
</script>

View File

@@ -0,0 +1,65 @@
<template>
<!--
Vue de detail d'une ligne d'audit : tableau field/old/new pour une
update, sinon snapshot complet sous forme de liste { cle: valeur }.
-->
<div class="text-sm">
<p class="text-xs text-gray-500 mb-2">
<span v-if="entry.ipAddress">IP: {{ entry.ipAddress }}</span>
<span v-if="entry.requestId" class="ml-3">Req: {{ entry.requestId }}</span>
</p>
<div v-if="entry.action === 'update'">
<table class="min-w-full border border-gray-200 text-xs">
<thead class="bg-gray-100">
<tr>
<th class="px-2 py-1 text-left font-medium">{{ t('audit.detail.field') }}</th>
<th class="px-2 py-1 text-left font-medium">{{ t('audit.detail.old_value') }}</th>
<th class="px-2 py-1 text-left font-medium">{{ t('audit.detail.new_value') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(diff, field) in updateDiff" :key="field" class="border-t border-gray-200">
<td class="px-2 py-1 font-mono">{{ field }}</td>
<td class="px-2 py-1 text-red-700">{{ formatValue(diff.old) }}</td>
<td class="px-2 py-1 text-green-700">{{ formatValue(diff.new) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="space-y-1">
<div v-for="(value, key) in entry.changes" :key="key" class="flex gap-2">
<span class="font-mono text-xs text-gray-600">{{ key }}:</span>
<span class="text-xs">{{ formatValue(value) }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { AuditLogEntry } from '~/shared/types'
const props = defineProps<{ entry: AuditLogEntry }>()
const { t } = useI18n()
// Extrait les entrees au shape { old, new } pour les updates.
const updateDiff = computed<Record<string, { old: unknown; new: unknown }>>(() => {
const out: Record<string, { old: unknown; new: unknown }> = {}
for (const [key, value] of Object.entries(props.entry.changes)) {
if (value && typeof value === 'object' && 'old' in value && 'new' in value) {
out[key] = value as { old: unknown; new: unknown }
}
}
return out
})
function formatValue(value: unknown): string {
if (value === null || value === undefined) return '∅'
if (typeof value === 'boolean') return value ? 'oui' : 'non'
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
</script>

View File

@@ -0,0 +1,204 @@
<template>
<!-- Garde permission : aucun rendu ni appel API si l'utilisateur n'a pas le droit. -->
<div v-if="!canView" />
<div v-else class="audit-timeline">
<!-- Skeleton loader initial -->
<ul v-if="loading && entries.length === 0" class="space-y-3">
<li v-for="i in 3" :key="i" class="flex gap-3">
<div class="h-3 w-3 rounded-full bg-gray-200 animate-pulse mt-1.5" />
<div class="flex-1 space-y-2">
<div class="h-3 w-1/3 rounded bg-gray-200 animate-pulse" />
<div class="h-2 w-2/3 rounded bg-gray-100 animate-pulse" />
</div>
</li>
</ul>
<p
v-else-if="!loading && entries.length === 0"
class="text-sm text-gray-500 italic"
>
{{ t('audit.timeline.empty') }}
</p>
<ul v-else class="relative border-l-2 border-gray-200 pl-6 space-y-5">
<li
v-for="entry in entries"
:key="entry.id"
class="relative"
>
<!-- Dot sur la barre verticale. Couleur selon action. -->
<span
class="absolute -left-[31px] top-1 h-3 w-3 rounded-full ring-2 ring-white"
:class="dotClass(entry.action)"
/>
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<p class="text-sm">
<span class="font-medium">{{ entry.performedBy }}</span>
<span class="text-gray-500"> {{ t(`audit.action.${entry.action}`) }}</span>
</p>
<!-- Update : diff field-by-field. Create/Delete : liste des champs. -->
<div v-if="entry.action === 'update'" class="mt-1 text-xs text-gray-600 space-y-0.5">
<div v-for="(diff, field) in updateDiff(entry)" :key="field">
<span class="font-medium">{{ field }}</span> :
<span class="line-through text-red-600">{{ formatValue(diff.old) }}</span>
<span class="mx-1"></span>
<span class="text-green-700">{{ formatValue(diff.new) }}</span>
</div>
</div>
<div v-else class="mt-1 text-xs text-gray-600">
{{ snapshotSummary(entry) }}
</div>
</div>
<!-- Date relative FR + tooltip absolu -->
<time
:title="absoluteDate(entry.performedAt)"
class="shrink-0 text-xs text-gray-500"
>
{{ relativeDate(entry.performedAt) }}
</time>
</div>
</li>
</ul>
<!-- Lazy loading : bouton "Voir plus" si plus de pages. -->
<div v-if="hasMore" class="mt-4 flex justify-center">
<button
type="button"
class="px-3 py-1.5 text-sm rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-60"
:disabled="loading"
@click="loadMore"
>
{{ loading ? t('common.loading') : t('audit.timeline.load_more') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, toRefs, watch } from 'vue'
import type { AuditLogEntry } from '~/shared/types'
const props = defineProps<{
entityType: string
entityId: string | number
}>()
const { entityType, entityId } = toRefs(props)
const { t } = useI18n()
const { can } = usePermissions()
const { fetchEntityLogs } = useAuditLog()
const canView = computed(() => can('core.audit_log.view'))
const entries = ref<AuditLogEntry[]>([])
const page = ref(1)
const totalItems = ref(0)
const loading = ref(false)
// Lazy loading : 10 items max par page visible cote UX. Le back fixe la
// limite a 30 (paginationItemsPerPage de AuditLogResource) ; on coupe a 10
// dans le composant pour ne pas saturer le flux visuel, et on laisse
// l'utilisateur demander plus via "Voir plus".
const INITIAL_LIMIT = 10
const hasMore = computed(() => entries.value.length < totalItems.value)
async function loadPage(targetPage: number, append: boolean): Promise<void> {
if (!canView.value) return
loading.value = true
try {
const data = await fetchEntityLogs(entityType.value, entityId.value, targetPage)
const slice = (data['hydra:member'] ?? []).slice(0, append ? undefined : INITIAL_LIMIT)
entries.value = append ? [...entries.value, ...slice] : slice
totalItems.value = data['hydra:totalItems'] ?? entries.value.length
page.value = targetPage
} catch {
// Erreur silencieuse (timeline secondaire) — useApi n'affiche pas de toast avec toast: false.
entries.value = append ? entries.value : []
} finally {
loading.value = false
}
}
async function loadMore(): Promise<void> {
await loadPage(page.value + 1, true)
}
function dotClass(action: string): string {
switch (action) {
case 'create': return 'bg-green-500'
case 'update': return 'bg-yellow-500'
case 'delete': return 'bg-red-500'
default: return 'bg-gray-400'
}
}
// Relativise une date en francais via Intl.RelativeTimeFormat. On selectionne
// l'unite la plus grossiere possible (minutes < heures < jours < semaines).
const rtf = new Intl.RelativeTimeFormat('fr', { numeric: 'auto' })
function relativeDate(iso: string): string {
const diffMs = Date.now() - new Date(iso).getTime()
const diffSec = Math.round(diffMs / 1000)
const absSec = Math.abs(diffSec)
if (absSec < 60) return rtf.format(-Math.sign(diffSec) * Math.abs(diffSec), 'second')
if (absSec < 3600) return rtf.format(-Math.sign(diffSec) * Math.round(absSec / 60), 'minute')
if (absSec < 86400) return rtf.format(-Math.sign(diffSec) * Math.round(absSec / 3600), 'hour')
if (absSec < 604800) return rtf.format(-Math.sign(diffSec) * Math.round(absSec / 86400), 'day')
return rtf.format(-Math.sign(diffSec) * Math.round(absSec / 604800), 'week')
}
function absoluteDate(iso: string): string {
return new Date(iso).toLocaleString('fr-FR', {
dateStyle: 'medium',
timeStyle: 'short',
})
}
function updateDiff(entry: AuditLogEntry): Record<string, { old: unknown; new: unknown }> {
// Format attendu: { champ: { old, new } }. On filtre defensivement les
// valeurs qui ne correspondent pas a ce shape (pas d'erreur runtime).
const out: Record<string, { old: unknown; new: unknown }> = {}
for (const [key, value] of Object.entries(entry.changes)) {
if (value && typeof value === 'object' && 'old' in value && 'new' in value) {
const diff = value as { old: unknown; new: unknown }
out[key] = diff
}
}
return out
}
function snapshotSummary(entry: AuditLogEntry): string {
const keys = Object.keys(entry.changes)
if (keys.length === 0) return '—'
if (keys.length <= 4) return keys.join(', ')
return `${keys.slice(0, 4).join(', ')}`
}
function formatValue(value: unknown): string {
if (value === null || value === undefined) return '∅'
if (typeof value === 'boolean') return value ? 'oui' : 'non'
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
// Reload si l'entite affichee change.
watch([entityType, entityId], () => {
entries.value = []
page.value = 1
totalItems.value = 0
loadPage(1, false)
})
onMounted(() => {
loadPage(1, false)
})
</script>

View File

@@ -0,0 +1,89 @@
import { ref } from 'vue'
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
import type { HydraCollection } from '~/shared/utils/api'
import { onAuthSessionCleared } from '~/shared/stores/auth'
/**
* Cache module-level : evite un double-fetch si la page et le composant
* Timeline demandent la meme page simultanement. Volontairement minimaliste :
* on ne cache que le dernier resultat, pas un LRU par filtre — un CRM interne
* n'en a pas besoin et le cache complexe complique le reset.
*
* Un logout / 401 doit purger ce cache : on s'enregistre au callback
* `onAuthSessionCleared` expose par auth.ts.
*/
const lastCollection = ref<HydraCollection<AuditLogEntry> | null>(null)
function resetAuditLog(): void {
lastCollection.value = null
}
// Auto-enregistrement singleton : si la session est invalidee (401,
// logout) le cache est purge automatiquement, evitant qu'un autre user
// connecte ensuite ne voit des donnees residuelles.
onAuthSessionCleared(resetAuditLog)
/**
* Traduit le modele front (camelCase) en query params API Platform
* (snake_case, avec la syntaxe performed_at[after] / [before]).
*
* @returns objet plat directement consommable par `useApi().get(url, query)`.
*/
function buildQuery(filters: AuditLogFilters | undefined): Record<string, string | number> {
const query: Record<string, string | number> = {}
if (!filters) return query
if (filters.entityType) query.entity_type = filters.entityType
if (filters.entityId) query.entity_id = filters.entityId
if (filters.action) query.action = filters.action
if (filters.performedBy) query.performed_by = filters.performedBy
if (filters.performedAtAfter) query['performed_at[after]'] = filters.performedAtAfter
if (filters.performedAtBefore) query['performed_at[before]'] = filters.performedAtBefore
if (filters.page) query.page = filters.page
return query
}
/**
* Composable partage entre la page globale d'audit (admin) et le composant
* Timeline. Expose des methodes de lecture + une fonction `resetAuditLog()`
* pour purger le cache (conforme a la regle CLAUDE.md sur les composables
* singletons, cf. `useSidebar.resetSidebar`).
*/
export function useAuditLog() {
const api = useApi()
async function fetchLogs(filters?: AuditLogFilters): Promise<HydraCollection<AuditLogEntry>> {
const data = await api.get<HydraCollection<AuditLogEntry>>(
'/audit-logs',
buildQuery(filters),
{ toast: false },
)
lastCollection.value = data
return data
}
async function fetchLogById(id: string): Promise<AuditLogEntry> {
return api.get<AuditLogEntry>(`/audit-logs/${id}`, {}, { toast: false })
}
async function fetchEntityLogs(
entityType: string,
entityId: string | number,
page: number = 1,
): Promise<HydraCollection<AuditLogEntry>> {
return fetchLogs({
entityType,
entityId: String(entityId),
page,
})
}
return {
lastCollection,
fetchLogs,
fetchLogById,
fetchEntityLogs,
resetAuditLog,
}
}

View File

@@ -9,3 +9,37 @@ export interface SidebarSection {
icon: string
items: SidebarItem[]
}
/**
* Entree d'audit telle qu'elle est renvoyee par GET /api/audit-logs.
*
* `changes` est un payload libre dont le format depend de `action` :
* - `create` / `delete` : snapshot complet { champ: valeur } ;
* - `update` : diff { champ: { old, new } }.
*/
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
}
/**
* Filtres combinables en query params (AND) pour GET /api/audit-logs.
* Les bornes de date utilisent la syntaxe API Platform `performed_at[after]` /
* `performed_at[before]`.
*/
export interface AuditLogFilters {
entityType?: string
entityId?: string
action?: string
performedBy?: string
performedAtAfter?: string
performedAtBefore?: string
page?: number
}

View File

@@ -1,6 +1,16 @@
export interface HydraView {
'@id'?: string
'@type'?: string
'hydra:first'?: string
'hydra:last'?: string
'hydra:next'?: string
'hydra:previous'?: string
}
export interface HydraCollection<T> {
'hydra:member': T[]
'hydra:totalItems': number
'hydra:view'?: HydraView
}
export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Audit log — Ticket 1 : table append-only `audit_log`.
*
* Table non geree par Doctrine ORM (aucune entite associee). Ecriture via
* DBAL uniquement par l'AuditLogWriter pour eviter la recursion du listener
* Doctrine (flush re-entrant). Colonnes en minuscules snake_case comme
* partout dans le projet.
*
* Type natif PostgreSQL `uuid` (16 octets) plutot que varchar(36) : index
* 40% plus petit sur une table append-only a croissance infinie.
*
* Migration placee au namespace racine `DoctrineMigrations` a cause du bug
* de tri FQCN alphabetique de Doctrine Migrations 3.x documente dans
* CLAUDE.md.
*/
final class Version20260420202749 extends AbstractMigration
{
public function getDescription(): string
{
return 'Audit log : creation de la table append-only audit_log + index.';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE audit_log (
id uuid NOT NULL,
entity_type VARCHAR(100) NOT NULL,
entity_id VARCHAR(64) NOT NULL,
action VARCHAR(10) NOT NULL,
changes JSONB NOT NULL DEFAULT '{}'::jsonb,
performed_by VARCHAR(100) NOT NULL,
performed_at TIMESTAMP(0) WITH TIME ZONE NOT NULL,
ip_address VARCHAR(45) DEFAULT NULL,
request_id VARCHAR(36) DEFAULT NULL,
PRIMARY KEY(id)
)
SQL);
// Index pour recherche par entite (detail d'historique d'un objet).
$this->addSql('CREATE INDEX idx_audit_entity_time ON audit_log (entity_type, entity_id, performed_at)');
// Index pour recherche par utilisateur (qui a fait quoi).
$this->addSql('CREATE INDEX idx_audit_performer ON audit_log (performed_by, performed_at)');
// Index pour tri chronologique global (listing pagine DESC).
$this->addSql('CREATE INDEX idx_audit_time ON audit_log (performed_at)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE audit_log');
}
}

View File

@@ -16,6 +16,46 @@
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
<server name="KERNEL_CLASS" value="App\Kernel" />
<!-- ###+ symfony/framework-bundle ### -->
<!-- APP_ENV est force a "test" en <server> ci-dessus : on ne doit PAS
re-injecter "dev" ici via <env>, sinon la suite tourne sous
framework.test=false et `test.service_container` n'est pas cable
(cf. cc8d5 du fix pre-existant). -->
<env name="APP_ENV" value="test"/>
<env name="APP_SECRET" value=""/>
<env name="APP_SHARE_DIR" value="var/share"/>
<!-- ###- symfony/framework-bundle ### -->
<!-- ###+ symfony/routing ### -->
<!-- Configure how to generate URLs in non-HTTP contexts, such as CLI commands. -->
<!-- See https://symfony.com/doc/current/routing.html#generating-urls-in-commands -->
<env name="DEFAULT_URI" value="http://localhost"/>
<!-- ###- symfony/routing ### -->
<!-- ###+ doctrine/doctrine-bundle ### -->
<!-- Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url -->
<!-- IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml -->
<!-- -->
<!-- DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db" -->
<!-- DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" -->
<!-- DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" -->
<env name="DATABASE_URL" value="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&amp;charset=utf8"/>
<!-- ###- doctrine/doctrine-bundle ### -->
<!-- ###+ lexik/jwt-authentication-bundle ### -->
<env name="JWT_SECRET_KEY" value="%kernel.project_dir%/config/jwt/private.pem"/>
<env name="JWT_PUBLIC_KEY" value="%kernel.project_dir%/config/jwt/public.pem"/>
<!-- Doit correspondre a la passphrase utilisee lors de la generation
des cles JWT (config/jwt/*.pem). En local dev, c'est la valeur
par defaut "change_me_in_env_local" du .env (override possible
via .env.test.local si les cles ont ete regenerees autrement). -->
<env name="JWT_PASSPHRASE" value="change_me_in_env_local"/>
<!-- ###- lexik/jwt-authentication-bundle ### -->
<!-- ###+ nelmio/cors-bundle ### -->
<env name="CORS_ALLOW_ORIGIN" value="'^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'"/>
<!-- ###- nelmio/cors-bundle ### -->
</php>
<testsuites>

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Application\DTO;
use DateTimeImmutable;
/**
* DTO de sortie pour une ligne d'audit.
*
* Readonly : aucune mutation possible apres hydration. La resource API
* Platform expose directement ce DTO (pas d'entite sous-jacente car la
* table audit_log n'est pas geree par l'ORM).
*/
final readonly class AuditLogOutput
{
public function __construct(
public string $id,
public string $entityType,
public string $entityId,
public string $action,
/** @var array<string, mixed> */
public array $changes,
public string $performedBy,
public DateTimeImmutable $performedAt,
public ?string $ipAddress,
public ?string $requestId,
) {}
}

View File

@@ -34,6 +34,7 @@ final class CoreModule
['code' => 'core.users.manage', 'label' => 'Gerer les utilisateurs (creer, editer, supprimer)'],
['code' => 'core.roles.view', 'label' => 'Voir les roles RBAC'],
['code' => 'core.roles.manage', 'label' => 'Gerer les roles et permissions'],
['code' => 'core.audit_log.view', 'label' => 'Consulter le journal d\'audit'],
];
}
}

View File

@@ -21,6 +21,8 @@ use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
// (targetEntity) via FQCN string, ce qui est obligatoire pour Doctrine.
// SiteNotAuthorizedException est importee depuis Shared (sa location canonique).
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Exception\SiteNotAuthorizedException;
use DateTimeImmutable;
@@ -63,6 +65,7 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
)]
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
#[ORM\Table(name: '`user`')]
#[Auditable]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
@@ -155,9 +158,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private ?SiteInterface $currentSite = null;
#[ORM\Column]
#[AuditIgnore]
private ?string $password = null;
#[Groups(['user:write'])]
#[AuditIgnore]
private ?string $plainPassword = null;
#[ORM\Column(type: 'datetime_immutable')]

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\Pagination;
use ApiPlatform\State\Pagination\PaginatorInterface;
use ArrayIterator;
use IteratorAggregate;
use Traversable;
/**
* Paginator pour resources alimentees par DBAL (pas par Doctrine ORM).
*
* Implemente PaginatorInterface : API Platform l'introspecte pour generer
* automatiquement la section `hydra:view` (first / next / previous / last)
* dans la reponse JSON-LD. Aucun calcul manuel de liens.
*
* @template T of object
*
* @implements PaginatorInterface<T>
*/
final readonly class DbalPaginator implements PaginatorInterface, IteratorAggregate
{
/**
* @param list<T> $items Items deja decoupes sur la page courante
* @param int $currentPage Page courante (1-indexee)
* @param int $itemsPerPage Limite appliquee a la requete SQL
* @param int $totalItems Resultat du COUNT(*) sans limite
*/
public function __construct(
private array $items,
private int $currentPage,
private int $itemsPerPage,
private int $totalItems,
) {}
public function getCurrentPage(): float
{
return (float) $this->currentPage;
}
public function getLastPage(): float
{
if ($this->itemsPerPage <= 0) {
return 1.0;
}
return (float) max(1, (int) ceil($this->totalItems / $this->itemsPerPage));
}
public function getItemsPerPage(): float
{
return (float) $this->itemsPerPage;
}
public function getTotalItems(): float
{
return (float) $this->totalItems;
}
public function count(): int
{
return count($this->items);
}
/**
* @return Traversable<int, T>
*/
public function getIterator(): Traversable
{
return new ArrayIterator($this->items);
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Core\Application\DTO\AuditLogOutput;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogProvider;
/**
* Resource API Platform en lecture seule sur le journal d'audit.
*
* Aucune operation d'ecriture exposee (POST/PUT/PATCH/DELETE -> 405)
* conformement au caractere append-only de la table `audit_log`.
*
* La resource est un simple porteur de metadonnees #[ApiResource] ; le
* provider lit via DBAL et retourne directement des instances du DTO
* `AuditLogOutput` (declare via `output:`). La table n'est pas geree par
* l'ORM : aucune entite Doctrine n'est necessaire ici.
*
* Filtres query-param supportes par le provider :
* ?entity_type=core.User
* ?entity_id=42
* ?action=update
* ?performed_by=admin
* ?performed_at[after]=2026-04-01T00:00:00Z
* ?performed_at[before]=2026-04-30T23:59:59Z
*
* La pagination est assuree par le provider via DbalPaginator (implementant
* ApiPlatform\State\Pagination\PaginatorInterface), ce qui genere
* automatiquement hydra:view — aucune construction manuelle.
*/
#[ApiResource(
shortName: 'AuditLog',
operations: [
new GetCollection(
uriTemplate: '/audit-logs',
paginationItemsPerPage: 30,
security: "is_granted('core.audit_log.view')",
provider: AuditLogProvider::class,
),
new Get(
uriTemplate: '/audit-logs/{id}',
security: "is_granted('core.audit_log.view')",
provider: AuditLogProvider::class,
),
],
output: AuditLogOutput::class,
)]
final class AuditLogResource {}

View File

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Core\Application\DTO\AuditLogOutput;
use App\Module\Core\Infrastructure\ApiPlatform\Pagination\DbalPaginator;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider API Platform pour la resource AuditLog.
*
* Lit la table `audit_log` via DBAL (pas d'entite ORM). Retourne soit :
* - une instance unique d'AuditLogOutput (operation Get) ;
* - un DbalPaginator de AuditLogOutput (operation GetCollection).
*
* Le paginator implementant PaginatorInterface laisse API Platform generer
* automatiquement la section `hydra:view` : aucune manipulation manuelle.
*
* Connexion DBAL : `default` (lecture — aucun besoin de la connexion `audit`
* reservee a l'ecriture hors transaction ORM).
*/
final readonly class AuditLogProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'doctrine.dbal.default_connection')]
private Connection $connection,
private Pagination $pagination,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AuditLogOutput|DbalPaginator|null
{
if (!$operation instanceof CollectionOperationInterface) {
return $this->provideItem((string) $uriVariables['id']);
}
return $this->provideCollection($operation, $context);
}
private function provideItem(string $id): ?AuditLogOutput
{
/** @var array<string, mixed>|false $row */
$row = $this->connection->fetchAssociative(
'SELECT id, entity_type, entity_id, action, changes, performed_by, performed_at, ip_address, request_id
FROM audit_log WHERE id = :id',
['id' => $id],
);
if (false === $row) {
return null;
}
return $this->hydrate($row);
}
/**
* @param array<string, mixed> $context
*/
private function provideCollection(Operation $operation, array $context): DbalPaginator
{
$page = $this->pagination->getPage($context);
$itemsPerPage = $this->pagination->getLimit($operation, $context);
$offset = ($page - 1) * $itemsPerPage;
$filters = $this->extractFilters($context['filters'] ?? []);
$dataQuery = $this->buildBaseQuery()
->select('id', 'entity_type', 'entity_id', 'action', 'changes', 'performed_by', 'performed_at', 'ip_address', 'request_id')
->orderBy('performed_at', 'DESC')
->setFirstResult($offset)
->setMaxResults($itemsPerPage)
;
$countQuery = $this->buildBaseQuery()->select('COUNT(*)');
$this->applyFilters($dataQuery, $filters);
$this->applyFilters($countQuery, $filters);
/** @var list<array<string, mixed>> $rows */
$rows = $dataQuery->executeQuery()->fetchAllAssociative();
$totalItems = (int) $countQuery->executeQuery()->fetchOne();
$items = array_map(fn (array $row) => $this->hydrate($row), $rows);
return new DbalPaginator($items, $page, $itemsPerPage, $totalItems);
}
private function buildBaseQuery(): QueryBuilder
{
return $this->connection->createQueryBuilder()->from('audit_log');
}
/**
* @param array<string, mixed> $raw
*
* @return array{entity_type?: string, entity_id?: string, action?: string, performed_by?: string, performed_at_after?: string, performed_at_before?: string}
*/
private function extractFilters(array $raw): array
{
$filters = [];
foreach (['entity_type', 'entity_id', 'action', 'performed_by'] as $key) {
if (isset($raw[$key]) && is_string($raw[$key]) && '' !== $raw[$key]) {
$filters[$key] = $raw[$key];
}
}
// Filtres de plage `performed_at[after]` / `performed_at[before]`.
if (isset($raw['performed_at']) && is_array($raw['performed_at'])) {
$range = $raw['performed_at'];
if (isset($range['after']) && is_string($range['after']) && '' !== $range['after']) {
$filters['performed_at_after'] = $range['after'];
}
if (isset($range['before']) && is_string($range['before']) && '' !== $range['before']) {
$filters['performed_at_before'] = $range['before'];
}
}
return $filters;
}
/**
* @param array<string, string> $filters
*/
private function applyFilters(QueryBuilder $qb, array $filters): void
{
if (isset($filters['entity_type'])) {
$qb->andWhere('entity_type = :entity_type')->setParameter('entity_type', $filters['entity_type']);
}
if (isset($filters['entity_id'])) {
$qb->andWhere('entity_id = :entity_id')->setParameter('entity_id', $filters['entity_id']);
}
if (isset($filters['action'])) {
$qb->andWhere('action = :action')->setParameter('action', $filters['action']);
}
if (isset($filters['performed_by'])) {
$qb->andWhere('performed_by = :performed_by')->setParameter('performed_by', $filters['performed_by']);
}
if (isset($filters['performed_at_after'])) {
$qb->andWhere('performed_at >= :performed_at_after')->setParameter('performed_at_after', $filters['performed_at_after']);
}
if (isset($filters['performed_at_before'])) {
$qb->andWhere('performed_at <= :performed_at_before')->setParameter('performed_at_before', $filters['performed_at_before']);
}
}
/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): AuditLogOutput
{
/** @var string $rawChanges */
$rawChanges = $row['changes'] ?? '{}';
/** @var array<string, mixed> $changes */
$changes = is_array($rawChanges) ? $rawChanges : json_decode((string) $rawChanges, true, 512, JSON_THROW_ON_ERROR);
return new AuditLogOutput(
id: (string) $row['id'],
entityType: (string) $row['entity_type'],
entityId: (string) $row['entity_id'],
action: (string) $row['action'],
changes: $changes,
performedBy: (string) $row['performed_by'],
performedAt: new DateTimeImmutable((string) $row['performed_at']),
ipAddress: null !== $row['ip_address'] ? (string) $row['ip_address'] : null,
requestId: null !== $row['request_id'] ? (string) $row['request_id'] : null,
);
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Audit;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Types\Types;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Uid\Uuid;
/**
* Service bas-niveau responsable de l'ecriture dans la table `audit_log`.
*
* Utilise une connexion DBAL dediee `audit` (meme DSN que `default`, service
* separe) pour ecrire hors de la transaction ORM : indispensable pour que
* les lignes d'audit survivent meme si le flush applicatif est rollback,
* et pour eviter tout entanglement transactionnel en batch (fixtures).
*
* Les cles sensibles (password, plainPassword, token, secret) sont filtrees
* en defense-in-depth meme si les entites declarent deja ces proprietes
* #[AuditIgnore].
*
* Erreur silencieuse : en cas d'echec SQL, on lance pas l'exception plus
* haut — l'audit ne doit jamais faire crasher un flux metier. Le listener
* wrappe l'appel dans un try/catch + logger (cf. AuditListener).
*/
final class AuditLogWriter
{
/** @var list<string> cles systematiquement strippees du payload `changes` */
private const array SENSITIVE_KEYS = ['password', 'plainPassword', 'token', 'secret'];
public function __construct(
#[Autowire(service: 'doctrine.dbal.audit_connection')]
private readonly Connection $connection,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly RequestIdProvider $requestIdProvider,
) {}
/**
* Ecrit une ligne d'audit.
*
* @param string $entityType Format "module.Entity" (ex: "core.User")
* @param string $entityId ID de l'entite (int ou UUID serialise)
* @param string $action create|update|delete
* @param array<string, mixed> $changes Payload JSON (filtre des cles sensibles)
*/
public function log(
string $entityType,
string $entityId,
string $action,
array $changes,
): void {
$filteredChanges = $this->stripSensitive($changes);
$this->connection->insert('audit_log', [
'id' => Uuid::v7()->toRfc4122(),
'entity_type' => $entityType,
'entity_id' => $entityId,
'action' => $action,
'changes' => $filteredChanges,
'performed_by' => $this->security->getUser()?->getUserIdentifier() ?? 'system',
'performed_at' => new DateTimeImmutable('now', new DateTimeZone('UTC')),
'ip_address' => $this->requestStack->getCurrentRequest()?->getClientIp(),
'request_id' => $this->requestIdProvider->getRequestId(),
], [
// Types de conversion DBAL : JSON encode jsonb + datetimetz.
'changes' => Types::JSON,
'performed_at' => Types::DATETIMETZ_IMMUTABLE,
]);
}
/**
* Supprime recursivement les cles sensibles du payload.
*
* Utile pour les snapshots complets (create/delete) ou les changes
* d'update : le listener prefiltre deja mais on garde cette garde
* en defense-in-depth si un appelant direct oublie `#[AuditIgnore]`.
*
* @param array<string, mixed> $data
*
* @return array<string, mixed>
*/
private function stripSensitive(array $data): array
{
foreach (self::SENSITIVE_KEYS as $sensitiveKey) {
unset($data[$sensitiveKey]);
}
return $data;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Audit;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Uid\Uuid;
/**
* Fournit un identifiant de requete HTTP (UUID v4) partage par toutes les
* lignes d'audit produites au cours d'une meme requete principale.
*
* Utilite : retrouver d'un seul coup d'oeil toutes les ecritures liees a un
* meme appel utilisateur (ex: PATCH qui cascade des updates sur plusieurs
* entites). Null en CLI (fixtures, commandes batch).
*
* Service singleton (scope container par defaut) — un unique UUID est
* genere au kernel.request principal et reutilise pour toute la requete.
*/
final class RequestIdProvider
{
private ?string $requestId = null;
#[AsEventListener(event: 'kernel.request')]
public function onKernelRequest(RequestEvent $event): void
{
// Ignorer les sub-requests (ESI, forward interne) pour ne pas
// ecraser l'UUID de la requete principale.
if (!$event->isMainRequest()) {
return;
}
$this->requestId = Uuid::v4()->toRfc4122();
}
public function getRequestId(): ?string
{
return $this->requestId;
}
}

View File

@@ -0,0 +1,336 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Infrastructure\Audit\AuditLogWriter;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\UnitOfWork;
use Psr\Log\LoggerInterface;
use ReflectionClass;
use ReflectionProperty;
use Throwable;
/**
* Listener Doctrine qui produit les lignes d'audit pour les entites portant
* l'attribut #[Auditable].
*
* Pipeline en deux temps :
* 1. onFlush : on traverse UnitOfWork (insertions / updates / deletions) et
* on capture les changements en memoire. Aucune ecriture SQL cote audit
* a ce stade pour ne pas interferer avec la transaction ORM en cours.
* 2. postFlush : on ecrit via AuditLogWriter (connexion DBAL dediee).
*
* Pattern swap-and-clear dans postFlush :
* - on copie localement la liste des evenements ;
* - on vide la propriete pendingLogs immediatement ;
* - on itere la copie.
* Pourquoi : si une ecriture audit declenchait un flush re-entrant (cas rare,
* ex: callback listener externe), l'etat de pendingLogs serait deja nettoye —
* pas de double insertion, pas de boucle infinie.
*
* Erreurs silencieuses : un INSERT audit qui echoue est logue en error mais
* jamais propage. Acceptable pour un CRM interne ; a reconsiderer si besoin
* de garantie forte (dead-letter queue, retry).
*
* Limitations connues :
* - Les changements de collections ManyToMany ne sont pas tracees
* (`getEntityChangeSet()` ne les couvre pas). Extension future via
* `getScheduledCollectionUpdates()`.
* - Les ManyToOne sont tracees par ID (null-safe via `?->getId()`).
*/
#[AsDoctrineListener(event: Events::onFlush)]
#[AsDoctrineListener(event: Events::postFlush)]
final class AuditListener
{
/**
* Cache par FQCN : true si la classe porte #[Auditable], false sinon.
* Evite une ReflectionClass par entite a chaque flush.
*
* @var array<class-string, bool>
*/
private array $auditableCache = [];
/**
* Cache par FQCN : liste des noms de proprietes ignorees (#[AuditIgnore]).
*
* @var array<class-string, list<string>>
*/
private array $ignoredPropertiesCache = [];
/**
* Logs en attente d'ecriture (remplis en onFlush, consommes en postFlush).
*
* Pour les inserts, l'ID est assignee DURANT le flush : on capture la
* reference de l'entite et on resout l'ID au moment du postFlush.
*
* @var list<array{entity: object, metadata: ClassMetadata, entityType: string, action: string, changes: array<string, mixed>, capturedId: ?string}>
*/
private array $pendingLogs = [];
public function __construct(
private readonly AuditLogWriter $writer,
private readonly LoggerInterface $logger,
) {}
public function onFlush(OnFlushEventArgs $args): void
{
/** @var EntityManagerInterface $em */
$em = $args->getObjectManager();
$uow = $em->getUnitOfWork();
foreach ($uow->getScheduledEntityInsertions() as $entity) {
$this->capturePendingLog($entity, $em, $uow, 'create');
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
$this->capturePendingLog($entity, $em, $uow, 'update');
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
$this->capturePendingLog($entity, $em, $uow, 'delete');
}
}
public function postFlush(PostFlushEventArgs $args): void
{
// Swap-and-clear : protege d'un flush re-entrant (aucune double
// insertion meme si un callback utilisateur re-declenche un flush).
$logs = $this->pendingLogs;
$this->pendingLogs = [];
foreach ($logs as $log) {
// Pour les inserts, l'ID n'etait pas encore disponible en onFlush :
// on la resout maintenant (Doctrine l'a hydratee pendant le flush).
$entityId = $log['capturedId'] ?? $this->resolveEntityId($log['entity'], $log['metadata']);
if (null === $entityId) {
$this->logger->warning(
'AuditListener : impossible de resoudre l\'ID de l\'entite apres flush, entree ignoree',
['entityType' => $log['entityType'], 'action' => $log['action']]
);
continue;
}
try {
$this->writer->log(
$log['entityType'],
$entityId,
$log['action'],
$log['changes'],
);
} catch (Throwable $e) {
// Erreur audit : logue mais ne crashe jamais le flux metier.
$this->logger->error(
'Echec d\'ecriture audit_log',
[
'exception' => $e,
'entityType' => $log['entityType'],
'entityId' => $entityId,
'action' => $log['action'],
]
);
}
}
}
private function capturePendingLog(object $entity, EntityManagerInterface $em, UnitOfWork $uow, string $action): void
{
$class = $entity::class;
if (!$this->isAuditable($class)) {
return;
}
$metadata = $em->getClassMetadata($class);
$changes = match ($action) {
'update' => $this->buildUpdateChanges($entity, $uow, $class),
'create', 'delete' => $this->buildSnapshot($entity, $metadata, $class),
default => [],
};
if ('update' === $action && [] === $changes) {
// Flush sans changement reel sur une entite auditable : on n'emet pas.
return;
}
// Pour delete/update, l'ID est deja set en onFlush — on la capture
// maintenant (apres postFlush, l'entite detachee peut perdre sa ref
// dans l'identity map). Pour create (IDENTITY), l'ID est generee par
// le flush — on differe a postFlush.
$capturedId = 'create' === $action ? null : $this->resolveEntityId($entity, $metadata);
$this->pendingLogs[] = [
'entity' => $entity,
'metadata' => $metadata,
'entityType' => $this->formatEntityType($class),
'action' => $action,
'changes' => $changes,
'capturedId' => $capturedId,
];
}
/**
* Build du changeset "update" : {champ: {old, new}} a partir de
* `UnitOfWork::getEntityChangeSet()`. ManyToOne : on log l'ID,
* null-safe via `?->getId()`.
*
* @return array<string, array{old: mixed, new: mixed}>
*/
private function buildUpdateChanges(object $entity, UnitOfWork $uow, string $class): array
{
$changeSet = $uow->getEntityChangeSet($entity);
$ignored = $this->getIgnoredProperties($class);
$filteredChanges = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if (in_array($field, $ignored, true)) {
continue;
}
$filteredChanges[$field] = [
'old' => $this->normalizeValue($oldValue),
'new' => $this->normalizeValue($newValue),
];
}
return $filteredChanges;
}
/**
* Build d'un snapshot complet (create / delete) : lit toutes les
* proprietes non-ignorees via Reflection.
*
* @return array<string, mixed>
*/
private function buildSnapshot(object $entity, ClassMetadata $metadata, string $class): array
{
$ignored = $this->getIgnoredProperties($class);
$snapshot = [];
foreach ($metadata->getFieldNames() as $field) {
if (in_array($field, $ignored, true)) {
continue;
}
$snapshot[$field] = $this->normalizeValue($metadata->getFieldValue($entity, $field));
}
foreach ($metadata->getAssociationNames() as $assoc) {
if (in_array($assoc, $ignored, true)) {
continue;
}
$mapping = $metadata->getAssociationMapping($assoc);
// On ne snapshot que les references scalaires (to-one) ; les
// collections to-many sont volumineuses et souvent non utiles
// a figer dans un audit (cf. limitation ManyToMany).
if (!$metadata->isSingleValuedAssociation($assoc)) {
continue;
}
$related = $metadata->getFieldValue($entity, $assoc);
$snapshot[$assoc] = null !== $related && method_exists($related, 'getId')
? $related->getId()
: null;
}
return $snapshot;
}
private function isAuditable(string $class): bool
{
if (array_key_exists($class, $this->auditableCache)) {
return $this->auditableCache[$class];
}
$reflection = new ReflectionClass($class);
$isAuditable = [] !== $reflection->getAttributes(Auditable::class);
$this->auditableCache[$class] = $isAuditable;
return $isAuditable;
}
/**
* @return list<string>
*/
private function getIgnoredProperties(string $class): array
{
if (array_key_exists($class, $this->ignoredPropertiesCache)) {
return $this->ignoredPropertiesCache[$class];
}
$ignored = [];
$reflection = new ReflectionClass($class);
foreach ($reflection->getProperties(ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PRIVATE | ReflectionProperty::IS_PUBLIC) as $property) {
if ([] !== $property->getAttributes(AuditIgnore::class)) {
$ignored[] = $property->getName();
}
}
$this->ignoredPropertiesCache[$class] = $ignored;
return $ignored;
}
/**
* Transforme un FQCN `App\Module\Core\Domain\Entity\User` en `core.User`.
*
* Format `module.Entity` pour eviter les collisions inter-modules.
*/
private function formatEntityType(string $class): string
{
if (1 === preg_match('#^App\\\Module\\\(?<module>[^\\\]+)\\\.+\\\(?<entity>[^\\\]+)$#', $class, $matches)) {
return strtolower($matches['module']).'.'.$matches['entity'];
}
// Fallback : on retourne le FQCN complet si la regex ne matche pas
// (entite hors structure modulaire — ne devrait pas arriver).
return $class;
}
private function resolveEntityId(object $entity, ClassMetadata $metadata): ?string
{
$identifier = $metadata->getIdentifierValues($entity);
if ([] === $identifier) {
return null;
}
// Cle composee : on concatene les valeurs. Cas rare sur le projet.
return implode('-', array_map(static fn ($v) => (string) $v, $identifier));
}
/**
* Normalise une valeur pour encodage JSON stable.
*/
private function normalizeValue(mixed $value): mixed
{
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
if (is_object($value)) {
// Relation to-one non parsee par buildSnapshot (cas update sur
// un champ qui devient un objet) : on tente getId() si possible.
if (method_exists($value, 'getId')) {
return $value->getId();
}
return (string) $value;
}
return $value;
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Attribute;
use Attribute;
/**
* Marqueur a poser sur une propriete d'entite pour l'exclure du tracking audit.
*
* Usage typique : champs sensibles (password, token), champs bruyants (updatedAt
* si recalcule sur chaque ecriture), champs derives. L'AuditLogWriter porte
* deja une blacklist exact-match sur les noms les plus dangereux (password,
* plainPassword, token, secret) en defense-in-depth, mais la regle de base
* reste : annoter explicitement ce qu'on ne veut pas voir trace.
*/
#[Attribute(Attribute::TARGET_PROPERTY)]
final class AuditIgnore {}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Attribute;
use Attribute;
/**
* Marqueur a poser sur une entite Doctrine pour activer le tracking audit.
*
* Emplacement dans Shared (pas dans Core) pour que tous les modules puissent
* l'utiliser sans dependance circulaire vers Core.
*
* Regle projet (cf. doc/audit-log.md) : toute entite metier DOIT porter cet
* attribut, avec #[AuditIgnore] sur les champs sensibles ou bruyants.
*/
#[Attribute(Attribute::TARGET_CLASS)]
final class Auditable {}

View File

@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Api;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\DBAL\Connection;
use Symfony\Component\Uid\Uuid;
/**
* Tests fonctionnels de l'API `/api/audit-logs`.
*
* Invariants testes :
* - 401 sans authentification ;
* - 403 pour un user authentifie sans permission `core.audit_log.view` ;
* - 200 + JSON-LD pagine pour admin et user avec la permission ;
* - filtres `entity_type`, `action` operants ;
* - ordre `performed_at DESC` ;
* - aucune operation d'ecriture exposee (POST -> 405).
*
* Seed : on insere 3 lignes temoins directement via DBAL (pas via l'ORM)
* pour eviter la recursion du listener. Les lignes sont supprimees en
* tearDown par le request_id tag specifique au run.
*
* @internal
*/
final class AuditLogApiTest extends AbstractApiTestCase
{
private Connection $auditConnection;
private string $runTag;
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
/** @var Connection $conn */
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
$this->auditConnection = $conn;
$this->runTag = 'api_audit_'.bin2hex(random_bytes(4));
$this->seedAuditLog();
}
protected function tearDown(): void
{
$this->auditConnection->executeStatement(
'DELETE FROM audit_log WHERE request_id = :tag',
['tag' => $this->runTag],
);
parent::tearDown();
}
public function testUnauthenticatedRequestGets401(): void
{
$client = self::createClient();
$response = $client->request('GET', '/api/audit-logs');
self::assertSame(401, $response->getStatusCode());
}
public function testAuthenticatedUserWithoutPermissionGets403(): void
{
// Utilise `core.users.view` comme permission non-liee (l'user n'a pas audit_log.view).
$credentials = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$response = $client->request('GET', '/api/audit-logs');
self::assertSame(403, $response->getStatusCode());
}
public function testAuthenticatedUserWithPermissionGets200(): void
{
$credentials = $this->createUserWithPermission('core.audit_log.view');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$response = $client->request('GET', '/api/audit-logs');
self::assertSame(200, $response->getStatusCode());
$data = $response->toArray();
self::assertArrayHasKey('member', $data);
self::assertArrayHasKey('totalItems', $data);
}
public function testAdminGets200(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/audit-logs');
self::assertSame(200, $response->getStatusCode());
}
public function testFilterByEntityType(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/audit-logs?entity_type=core.User&action=update');
self::assertSame(200, $response->getStatusCode());
$data = $response->toArray();
$members = $data['member'];
// On verifie qu'il n'y a que des lignes matching nos filtres dans les resultats de notre run
// (d'autres lignes antérieures au run peuvent exister, mais le filtre doit etre respecte).
foreach ($members as $member) {
self::assertSame('core.User', $member['entityType']);
self::assertSame('update', $member['action']);
}
}
public function testOrderedByPerformedAtDesc(): void
{
$client = $this->authenticatedClient('admin', 'admin');
// On cible les 3 lignes seedees via le filtre `entity_id=999` (unique a ce test).
$response = $client->request('GET', '/api/audit-logs?'.http_build_query(['entity_type' => 'core.User', 'entity_id' => '999']));
self::assertSame(200, $response->getStatusCode());
$data = $response->toArray();
$members = array_values(array_filter(
$data['member'],
fn (array $m) => ($m['requestId'] ?? null) === $this->runTag,
));
self::assertCount(3, $members, 'Les 3 lignes seedees doivent etre visibles');
// Tri DESC : le plus recent d'abord.
$timestamps = array_map(fn (array $m) => strtotime((string) $m['performedAt']), $members);
$sortedDesc = $timestamps;
rsort($sortedDesc);
self::assertSame($sortedDesc, $timestamps, 'Les lignes doivent etre triees par performedAt DESC');
}
public function testItemEndpointReturns200WithPermission(): void
{
$row = $this->auditConnection->fetchAssociative(
'SELECT id FROM audit_log WHERE request_id = :tag LIMIT 1',
['tag' => $this->runTag],
);
self::assertIsArray($row);
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/audit-logs/'.$row['id']);
self::assertSame(200, $response->getStatusCode());
$data = $response->toArray();
self::assertSame($row['id'], $data['id']);
}
public function testPostIsNotAllowed(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('POST', '/api/audit-logs', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['entityType' => 'core.User', 'entityId' => '1', 'action' => 'create', 'changes' => []],
]);
self::assertContains($response->getStatusCode(), [404, 405], 'POST doit etre refuse (pas d\'operation d\'ecriture exposee)');
}
/**
* Insere 3 lignes temoins taggees avec le runTag pour un nettoyage sur.
*/
private function seedAuditLog(): void
{
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$fixtures = [
[
'entity_type' => 'core.User',
'entity_id' => '999',
'action' => 'update',
'changes' => ['isAdmin' => ['old' => false, 'new' => true]],
'performed_by' => 'admin',
'performed_at' => $now->modify('-2 hours'),
],
[
'entity_type' => 'core.User',
'entity_id' => '999',
'action' => 'update',
'changes' => ['username' => ['old' => 'x', 'new' => 'y']],
'performed_by' => 'admin',
'performed_at' => $now->modify('-1 hour'),
],
[
'entity_type' => 'core.User',
'entity_id' => '999',
'action' => 'delete',
'changes' => ['username' => 'y'],
'performed_by' => 'admin',
'performed_at' => $now,
],
];
foreach ($fixtures as $row) {
$this->auditConnection->insert('audit_log', [
'id' => Uuid::v7()->toRfc4122(),
'entity_type' => $row['entity_type'],
'entity_id' => $row['entity_id'],
'action' => $row['action'],
'changes' => json_encode($row['changes'], JSON_THROW_ON_ERROR),
'performed_by' => $row['performed_by'],
'performed_at' => $row['performed_at']->format('Y-m-d H:i:sO'),
'ip_address' => null,
'request_id' => $this->runTag,
]);
}
}
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Infrastructure\Audit;
use App\Module\Core\Infrastructure\Audit\AuditLogWriter;
use App\Module\Core\Infrastructure\Audit\RequestIdProvider;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\User\InMemoryUser;
/**
* Tests unitaires de l'AuditLogWriter.
*
* Verifie les invariants critiques :
* - filtrage des cles sensibles (defense-in-depth par rapport a #[AuditIgnore]) ;
* - utilisation du username courant ou "system" en CLI ;
* - captation IP + request_id si requete HTTP presente ;
* - generation d'un UUID v7 (tri chronologique implicite en PK).
*
* Aucune BDD : la connexion DBAL est mockee pour capturer l'insert.
*
* @internal
*/
#[AllowMockObjectsWithoutExpectations]
final class AuditLogWriterTest extends TestCase
{
/**
* @var null|array{0: string, 1: array<string, mixed>, 2: array<string, mixed>}
*
* Capture de l'appel `insert()` : [$table, $data, $types]
*/
private ?array $capturedInsert = null;
private Connection $connection;
private RequestStack $requestStack;
private RequestIdProvider $requestIdProvider;
protected function setUp(): void
{
$this->capturedInsert = null;
$this->connection = $this->createMock(Connection::class);
$this->connection
->method('insert')
->willReturnCallback(function (string $table, array $data, array $types = []): int {
$this->capturedInsert = [$table, $data, $types];
return 1;
})
;
$this->requestStack = new RequestStack();
$this->requestIdProvider = new RequestIdProvider();
}
public function testLogsCreateWithAuthenticatedUser(): void
{
$security = $this->buildSecurityWithUser('alice');
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
$writer->log('core.User', '42', 'create', ['username' => 'alice']);
$this->assertNotNull($this->capturedInsert);
[$table, $data] = $this->capturedInsert;
$this->assertSame('audit_log', $table);
$this->assertSame('core.User', $data['entity_type']);
$this->assertSame('42', $data['entity_id']);
$this->assertSame('create', $data['action']);
$this->assertSame(['username' => 'alice'], $data['changes']);
$this->assertSame('alice', $data['performed_by']);
}
public function testUsesSystemWhenNoAuthenticatedUser(): void
{
$security = $this->buildSecurityWithUser(null);
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
$writer->log('core.User', '1', 'update', ['isAdmin' => ['old' => false, 'new' => true]]);
$this->assertSame('system', $this->capturedInsert[1]['performed_by']);
}
public function testStripsSensitiveKeys(): void
{
$security = $this->buildSecurityWithUser('alice');
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
$writer->log('core.User', '1', 'create', [
'username' => 'bob',
'password' => 'topsecrethash',
'plainPassword' => 'clear',
'token' => 'abc',
'secret' => 'xyz',
'email' => 'bob@example.com',
]);
$changes = $this->capturedInsert[1]['changes'];
$this->assertArrayNotHasKey('password', $changes);
$this->assertArrayNotHasKey('plainPassword', $changes);
$this->assertArrayNotHasKey('token', $changes);
$this->assertArrayNotHasKey('secret', $changes);
$this->assertSame('bob', $changes['username']);
$this->assertSame('bob@example.com', $changes['email']);
}
public function testCapturesIpAddressWhenRequestPresent(): void
{
$request = Request::create('/api/users', 'POST');
$request->server->set('REMOTE_ADDR', '203.0.113.42');
$this->requestStack->push($request);
$security = $this->buildSecurityWithUser('alice');
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
$writer->log('core.User', '1', 'create', []);
$this->assertSame('203.0.113.42', $this->capturedInsert[1]['ip_address']);
}
public function testIpAddressNullInCli(): void
{
$security = $this->buildSecurityWithUser(null);
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
$writer->log('core.User', '1', 'create', []);
$this->assertNull($this->capturedInsert[1]['ip_address']);
$this->assertNull($this->capturedInsert[1]['request_id']);
}
public function testGeneratesUuidV7PrimaryKey(): void
{
$security = $this->buildSecurityWithUser('alice');
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
$writer->log('core.User', '1', 'create', []);
$id = $this->capturedInsert[1]['id'];
// UUID v7 : le 13e caractere (apres les tirets) vaut "7".
// Format : xxxxxxxx-xxxx-7xxx-xxxx-xxxxxxxxxxxx
$this->assertMatchesRegularExpression(
'/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i',
$id
);
}
private function buildSecurityWithUser(?string $username): Security
{
$security = $this->createMock(Security::class);
$user = null !== $username ? new InMemoryUser($username, 'pwd') : null;
$security->method('getUser')->willReturn($user);
return $security;
}
}

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Domain\Entity\User;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Tests d'integration de l'AuditListener.
*
* Contrairement aux tests unitaires du writer, on fait tourner le kernel
* complet pour verifier que le listener est bien cable et que les attributs
* #[Auditable] / #[AuditIgnore] sur User sont respectes jusqu'a l'insert
* final dans audit_log.
*
* Strategie de nettoyage : chaque test supprime ses fixtures dans tearDown
* (pas de rollback transactionnel DAMA sur ce projet).
*
* @internal
*/
final class AuditListenerTest extends KernelTestCase
{
private EntityManagerInterface $em;
private Connection $auditConnection;
/** @var list<int> IDs de users crees par le test (nettoyage en tearDown) */
private array $createdUserIds = [];
private string $testRunTag;
protected function setUp(): void
{
self::bootKernel();
/** @var EntityManagerInterface $em */
$em = self::getContainer()->get('doctrine')->getManager();
$this->em = $em;
/** @var Connection $conn */
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
$this->auditConnection = $conn;
// Tag unique par run pour filtrer les lignes audit_log produites
// exclusivement par ce test (la table n'a ni truncate ni rollback).
$this->testRunTag = 'audit_test_'.bin2hex(random_bytes(4));
}
protected function tearDown(): void
{
// Suppression explicite des users crees (cascade sur user_role /
// user_site via les ORM mappings) + nettoyage des lignes audit
// correspondantes pour ne pas polluer les runs suivants.
if ([] !== $this->createdUserIds) {
foreach ($this->createdUserIds as $id) {
$user = $this->em->find(User::class, $id);
if (null !== $user) {
$this->em->remove($user);
}
}
$this->em->flush();
}
$this->auditConnection->executeStatement(
"DELETE FROM audit_log WHERE entity_type = 'core.User' AND changes->>'username' LIKE :tag",
['tag' => $this->testRunTag.'%'],
);
parent::tearDown();
}
public function testLogsCreateOnUserInsertion(): void
{
$user = $this->makeUser();
$this->em->persist($user);
$this->em->flush();
$this->createdUserIds[] = $user->getId();
$rows = $this->fetchAuditRows($user->getId());
$this->assertCount(1, $rows, 'Une ligne audit attendue a la creation');
$row = $rows[0];
$this->assertSame('core.User', $row['entity_type']);
$this->assertSame('create', $row['action']);
$this->assertSame((string) $user->getId(), $row['entity_id']);
$changes = json_decode($row['changes'], true, 512, JSON_THROW_ON_ERROR);
$this->assertArrayHasKey('username', $changes);
$this->assertArrayNotHasKey('password', $changes, 'password doit etre #[AuditIgnore]');
$this->assertArrayNotHasKey('plainPassword', $changes, 'plainPassword doit etre #[AuditIgnore]');
}
public function testLogsUpdateWithDiff(): void
{
$user = $this->makeUser();
$this->em->persist($user);
$this->em->flush();
$this->createdUserIds[] = $user->getId();
// Reset de la baseline : on ne garde que la ligne update.
$this->auditConnection->executeStatement(
'DELETE FROM audit_log WHERE entity_id = :id AND entity_type = \'core.User\'',
['id' => (string) $user->getId()],
);
$user->setIsAdmin(true);
$this->em->flush();
$rows = $this->fetchAuditRows($user->getId());
$this->assertCount(1, $rows);
$this->assertSame('update', $rows[0]['action']);
$changes = json_decode($rows[0]['changes'], true, 512, JSON_THROW_ON_ERROR);
$this->assertArrayHasKey('isAdmin', $changes);
$this->assertSame(false, $changes['isAdmin']['old']);
$this->assertSame(true, $changes['isAdmin']['new']);
}
public function testLogsDeleteSnapshot(): void
{
$user = $this->makeUser();
$this->em->persist($user);
$this->em->flush();
$userId = $user->getId();
$this->em->remove($user);
$this->em->flush();
$rows = $this->fetchAuditRows($userId);
// Deux lignes : la creation + la suppression.
$actions = array_column($rows, 'action');
$this->assertContains('delete', $actions);
$deleteRow = $rows[array_search('delete', $actions, true)];
$changes = json_decode($deleteRow['changes'], true, 512, JSON_THROW_ON_ERROR);
$this->assertArrayHasKey('username', $changes);
$this->assertArrayNotHasKey('password', $changes);
// On nettoie a la main les lignes restantes (user deja delete).
$this->auditConnection->executeStatement(
'DELETE FROM audit_log WHERE entity_id = :id AND entity_type = \'core.User\'',
['id' => (string) $userId],
);
}
/**
* @return list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string}>
*/
private function fetchAuditRows(int $userId): array
{
/** @var list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string}> $rows */
return $this->auditConnection->fetchAllAssociative(
'SELECT id, entity_type, entity_id, action, changes FROM audit_log WHERE entity_type = :type AND entity_id = :id ORDER BY performed_at ASC',
['type' => 'core.User', 'id' => (string) $userId],
);
}
private function makeUser(): User
{
/** @var UserPasswordHasherInterface $hasher */
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$user = new User();
$user->setUsername($this->testRunTag.'_'.bin2hex(random_bytes(2)));
$user->setIsAdmin(false);
$user->setPassword($hasher->hashPassword($user, 'testpass'));
return $user;
}
}