# 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>` - `fetchLogById(id: string): Promise` - `fetchEntityLogs(entityType: string, entityId: string, page?: number): Promise>` 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 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)