# Audit Log — Specification technique

## Objectif

Tracer l'historique de toutes les modifications BDD dans une table `audit_log` append-only. L'audit est opt-in via l'attribut `#[Auditable]` sur les entites, expose en lecture seule via API Platform (permission RBAC `core.audit_log.view`), et visualise dans le frontend via une page dediee et un composant timeline reutilisable.

**Regle projet** : toute entite (nouvelle ou existante) doit etre annotee `#[Auditable]` avec `#[AuditIgnore]` sur les champs sensibles. L'audit n'est pas optionnel — il est obligatoire sur toutes les entites metier.

---

## Architecture

```
src/
  Shared/
    Domain/
      Attribute/
        Auditable.php              # Attribut classe — active le tracking
        AuditIgnore.php            # Attribut propriete — exclut un champ
  Module/
    Core/
      CoreModule.php               # + permission core.audit_log.view
      Application/
        DTO/
          AuditLogOutput.php       # DTO lecture seule
      Infrastructure/
        Audit/
          AuditLogWriter.php       # Ecrit via DBAL (pas Doctrine ORM)
          RequestIdProvider.php    # UUID v4 par requete HTTP
        Doctrine/
          AuditListener.php        # Listener onFlush/postFlush
          Migrations/              # (migration dans migrations/ racine — cf. bug tri FQCN)
        ApiPlatform/
          Resource/
            AuditLogResource.php   # ApiResource read-only
          State/
            Provider/
              AuditLogProvider.php  # Provider DBAL

frontend/
  shared/
    composables/
      useAuditLog.ts               # Composable partage (page + timeline)
    components/
      audit/
        AuditTimeline.vue          # Timeline verticale reutilisable
    types/
      index.ts                     # + AuditLogEntry, AuditLogFilters, HydraView
    utils/
      api.ts                       # + support hydra:view pagination
  modules/
    core/
      pages/
        admin/
          audit-log.vue            # Page globale admin
```

---

## Table PostgreSQL `audit_log`

Table non geree par Doctrine ORM (pas d'entite). Ecriture via DBAL uniquement pour eviter la recursion des listeners.

### Schema

| Colonne | Type | Contrainte | Description |
|---------|------|-----------|-------------|
| `id` | uuid | PK | UUID v7 genere en PHP (`Uuid::v7()->toRfc4122()`) — type natif PG (16 octets vs 36 en varchar) |
| `entity_type` | varchar(100) | NOT NULL | Format `module.Entity` (ex: `core.User`, `commercial.Client`) — evite les collisions inter-modules |
| `entity_id` | varchar(64) | NOT NULL | ID de l'entite (supporte int et UUID) |
| `action` | varchar(10) | NOT NULL | `create`, `update`, `delete` |
| `changes` | jsonb | NOT NULL DEFAULT '{}' | Changements (format selon action) |
| `performed_by` | varchar(100) | NOT NULL | Username denormalise (survit a la suppression du user) |
| `performed_at` | timestamptz | NOT NULL | Horodatage de l'action |
| `ip_address` | varchar(45) | NULL | Adresse IP (null en CLI) |
| `request_id` | varchar(36) | NULL | UUID v4 par requete HTTP (null en CLI) |

### Index

- `idx_audit_entity_time` : `(entity_type, entity_id, performed_at)` — recherche par entite
- `idx_audit_performer` : `(performed_by, performed_at)` — recherche par utilisateur
- `idx_audit_time` : `(performed_at)` — tri chronologique global

### Regles

- **Append-only** : pas d'UPDATE, pas de DELETE
- **Colonnes en minuscules** (convention PostgreSQL du projet)
- **Champs sensibles exclus** : `password`, `plainPassword`, `token`, `secret` ne doivent jamais apparaitre dans `changes`
- **`performed_by` denormalise** : string, pas FK — le nom persiste meme si l'utilisateur est supprime
- **Migration** : dans `migrations/` (namespace racine `DoctrineMigrations`) a cause du bug de tri alphabetique FQCN de Doctrine Migrations 3.x entre namespaces

### Contrat : ce que `audit_log` garantit (et ne garantit pas)

`audit_log` enregistre les **tentatives de modification** capturees par le `postFlush` Doctrine, ecrites via une connexion DBAL dediee (`audit_connection`). Ce choix est intentionnel : les lignes d'audit survivent au rollback eventuel de la transaction metier principale, ce qui permet de tracer les tentatives meme en cas d'echec applicatif.

**Conséquence à connaître** : si un controller enveloppe plusieurs operations dans une transaction explicite sur la connexion `default` et que cette transaction outermost rollback apres un flush intermediaire reussi, la ligne audit correspondante **persiste** sur la connexion `audit` alors que la modification metier a ete annulee. L'audit log peut donc contenir des lignes decrivant un etat qui n'existe pas en base metier.

En pratique :
- Ce cas est rare dans un CRM interne (les rollbacks explicites outermost sont marginaux par rapport aux flushes atomiques).
- La ligne audit garde son `request_id` qui permet une correlation post-mortem avec les logs applicatifs pour distinguer une tentative avortee d'un commit reussi.
- Le comportement est volontaire — pas un bug. Pour un besoin de garantie « audit = reflet exact du commit outermost », il faudrait basculer l'audit sur la meme connexion que le metier (voir `AuditLogWriter`), au prix de perdre la resilience au rollback partiel.

L'audit est donc un **journal des intentions appliquees par l'ORM**, pas une source de verite transactionnelle sur l'etat final de la DB.

---

## Composants backend

### `AuditLogWriter`

**Emplacement** : `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php`

Service responsable de l'ecriture dans `audit_log` via `Connection::executeStatement()`.

**Dependances** :
- `Doctrine\DBAL\Connection` — connexion DBAL dediee `audit` (meme DSN, service separe) pour eviter l'entanglement transactionnel avec l'ORM. Config : `doctrine.dbal.connections.audit` dans `doctrine.yaml`. Injection via `#[Autowire(service: 'doctrine.dbal.audit_connection')]`.
- `Symfony\Bundle\SecurityBundle\Security`
- `Symfony\Component\HttpFoundation\RequestStack`
- `RequestIdProvider`

**Methode principale** :
```php
public function log(
    string $entityType,
    string $entityId,
    string $action,
    array $changes,
): void
```

**Comportement** :
- Genere `id` via `Uuid::v7()->toRfc4122()`
- `performed_by` = `$security->getUser()?->getUserIdentifier() ?? 'system'`
- `ip_address` = `$requestStack->getCurrentRequest()?->getClientIp()`
- `request_id` = `$requestIdProvider->getRequestId()`
- `performed_at` = `new \DateTimeImmutable('now', new \DateTimeZone('UTC'))`
- Filtre les cles sensibles (`password`, `plainPassword`, `token`, `secret`) de `$changes`
- INSERT SQL brut via DBAL

**Necessite** : `composer require symfony/uid`

### `RequestIdProvider`

**Emplacement** : `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php`

Service singleton qui genere un UUID v4 unique par requete HTTP principale.

**Comportement** :
- Ecoute `kernel.request` via `#[AsEventListener]`
- Ignore les sub-requests : `if (!$event->isMainRequest()) return;`
- Genere `Uuid::v4()->toRfc4122()` a chaque requete principale
- Expose `getRequestId(): ?string` (null en CLI)

### Attributs `#[Auditable]` et `#[AuditIgnore]`

**Emplacement** : `src/Shared/Domain/Attribute/` (dans Shared, pas Core — tous les modules doivent y acceder)

- `#[Auditable]` : attribut de classe, marqueur vide. Active le tracking sur l'entite.
- `#[AuditIgnore]` : attribut de propriete, marqueur vide. Exclut un champ du tracking.

### `AuditListener`

**Emplacement** : `src/Module/Core/Infrastructure/Doctrine/AuditListener.php`

Listener Doctrine (pas EventSubscriber — deprecie Symfony 8) utilisant `#[AsDoctrineListener]`.

**Evenements** :
- `onFlush` : collecte les changesets (aucune ecriture)
- `postFlush` : ecrit via `AuditLogWriter` (hors transaction Doctrine)

**Dependances** :
- `AuditLogWriter`
- `LoggerInterface`

**Logique `onFlush`** :
1. Recupere `UnitOfWork`
2. Parcourt insertions, updates, deletions
3. Pour chaque entite : verifie `#[Auditable]` via `ReflectionClass::getAttributes()`
4. Filtre les proprietes `#[AuditIgnore]` + blacklist hardcodee
5. Formate les changements :
   - **create** : snapshot complet de toutes les proprietes non-ignorees
   - **update** : `{champ: {old: x, new: y}}` via `getEntityChangeSet()`
   - **delete** : snapshot complet
6. ManyToOne : log l'ID via `?->getId()` (null-safe pour les relations nullable), pas l'objet
7. Stocke dans `$pendingLogs` (propriete privee)

**Logique `postFlush`** — pattern swap-and-clear (protection contre flush re-entrant) :
1. Copie `$pendingLogs` dans variable locale, vide immediatement `$this->pendingLogs = []`
2. Pour chaque log copie → `AuditLogWriter::log()`
3. Try/catch : erreur → `$logger->error(...)`, jamais de crash

**Cas particuliers** :
- Flush sans changement → rien
- Entite sans `#[Auditable]` → ignoree
- Batch (fixtures, import) → chaque entite auditee, groupees par `request_id`
- Console → `performed_by = 'system'`, `ip_address = null`, `request_id = null`
- ManyToMany / OneToMany : tracees via `UnitOfWork::getScheduledCollectionUpdates()` et `getScheduledCollectionDeletions()` (cf. `AuditListener::captureCollectionChange`). Payload `{fieldName: {added: [ids], removed: [ids]}}`, merge dans le log deja en attente de l'entite proprietaire si elle est aussi scheduled (insertion → snapshot enrichi, update → diff merge, delete → ignore car redondant avec le snapshot delete).

---

## 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]`
- **Collections to-many auditees** : tracees via `getScheduledCollectionUpdates` / `getScheduledCollectionDeletions`, payload `{added, removed}` merge dans le changeset de l'entite proprietaire (cf. `AuditListener::captureCollectionChange`)
- **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)
