diff --git a/CLAUDE.md b/CLAUDE.md index 3559259..2c9e4de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/composer.json b/composer.json index a3a9b4f..ee7d9a9 100644 --- a/composer.json +++ b/composer.json @@ -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.*" }, diff --git a/composer.lock b/composer.lock index 43d0aeb..6914aca 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index fc3d6a9..bc1233e 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -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'] diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index c21cc4f..2864ce8 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -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 diff --git a/config/reference.php b/config/reference.php index c909b5c..d35cc73 100644 --- a/config/reference.php +++ b/config/reference.php @@ -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, diff --git a/config/sidebar.php b/config/sidebar.php index c82f02d..4c14db7 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -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', diff --git a/doc/audit-log.md b/doc/audit-log.md new file mode 100644 index 0000000..7838048 --- /dev/null +++ b/doc/audit-log.md @@ -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>` +- `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]` +- **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) diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 5170bfe..7527977 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -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", diff --git a/frontend/modules/core/pages/admin/audit-log.vue b/frontend/modules/core/pages/admin/audit-log.vue new file mode 100644 index 0000000..f4b6494 --- /dev/null +++ b/frontend/modules/core/pages/admin/audit-log.vue @@ -0,0 +1,356 @@ + + + diff --git a/frontend/shared/components/audit/AuditLogDetail.vue b/frontend/shared/components/audit/AuditLogDetail.vue new file mode 100644 index 0000000..e444347 --- /dev/null +++ b/frontend/shared/components/audit/AuditLogDetail.vue @@ -0,0 +1,65 @@ + + + diff --git a/frontend/shared/components/audit/AuditTimeline.vue b/frontend/shared/components/audit/AuditTimeline.vue new file mode 100644 index 0000000..61212d3 --- /dev/null +++ b/frontend/shared/components/audit/AuditTimeline.vue @@ -0,0 +1,204 @@ + + + diff --git a/frontend/shared/composables/useAuditLog.ts b/frontend/shared/composables/useAuditLog.ts new file mode 100644 index 0000000..778cc51 --- /dev/null +++ b/frontend/shared/composables/useAuditLog.ts @@ -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 | 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 { + const query: Record = {} + 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> { + const data = await api.get>( + '/audit-logs', + buildQuery(filters), + { toast: false }, + ) + lastCollection.value = data + return data + } + + async function fetchLogById(id: string): Promise { + return api.get(`/audit-logs/${id}`, {}, { toast: false }) + } + + async function fetchEntityLogs( + entityType: string, + entityId: string | number, + page: number = 1, + ): Promise> { + return fetchLogs({ + entityType, + entityId: String(entityId), + page, + }) + } + + return { + lastCollection, + fetchLogs, + fetchLogById, + fetchEntityLogs, + resetAuditLog, + } +} diff --git a/frontend/shared/types/index.ts b/frontend/shared/types/index.ts index 1e51be7..e7e9367 100644 --- a/frontend/shared/types/index.ts +++ b/frontend/shared/types/index.ts @@ -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 + 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 +} diff --git a/frontend/shared/utils/api.ts b/frontend/shared/utils/api.ts index 36affd1..fecbb8d 100644 --- a/frontend/shared/utils/api.ts +++ b/frontend/shared/utils/api.ts @@ -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 { 'hydra:member': T[] 'hydra:totalItems': number + 'hydra:view'?: HydraView } export function extractHydraMembers(collection: HydraCollection): T[] { diff --git a/migrations/Version20260420202749.php b/migrations/Version20260420202749.php new file mode 100644 index 0000000..abff7c2 --- /dev/null +++ b/migrations/Version20260420202749.php @@ -0,0 +1,63 @@ +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'); + } +} diff --git a/phpunit.dist.xml b/phpunit.dist.xml index eb794bd..a3c1c38 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -16,6 +16,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Module/Core/Application/DTO/AuditLogOutput.php b/src/Module/Core/Application/DTO/AuditLogOutput.php new file mode 100644 index 0000000..d84ad5a --- /dev/null +++ b/src/Module/Core/Application/DTO/AuditLogOutput.php @@ -0,0 +1,30 @@ + */ + public array $changes, + public string $performedBy, + public DateTimeImmutable $performedAt, + public ?string $ipAddress, + public ?string $requestId, + ) {} +} diff --git a/src/Module/Core/CoreModule.php b/src/Module/Core/CoreModule.php index 5b9b3d8..8b3be4f 100644 --- a/src/Module/Core/CoreModule.php +++ b/src/Module/Core/CoreModule.php @@ -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'], ]; } } diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index fc5106f..5f5a4a9 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -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')] diff --git a/src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php b/src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php new file mode 100644 index 0000000..adda23c --- /dev/null +++ b/src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php @@ -0,0 +1,74 @@ + + */ +final readonly class DbalPaginator implements PaginatorInterface, IteratorAggregate +{ + /** + * @param list $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 + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->items); + } +} diff --git a/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php b/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php new file mode 100644 index 0000000..94ee273 --- /dev/null +++ b/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php @@ -0,0 +1,53 @@ + 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 {} diff --git a/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php b/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php new file mode 100644 index 0000000..7a0848a --- /dev/null +++ b/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php @@ -0,0 +1,177 @@ +provideItem((string) $uriVariables['id']); + } + + return $this->provideCollection($operation, $context); + } + + private function provideItem(string $id): ?AuditLogOutput + { + /** @var array|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 $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> $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 $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 $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 $row + */ + private function hydrate(array $row): AuditLogOutput + { + /** @var string $rawChanges */ + $rawChanges = $row['changes'] ?? '{}'; + + /** @var array $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, + ); + } +} diff --git a/src/Module/Core/Infrastructure/Audit/AuditLogWriter.php b/src/Module/Core/Infrastructure/Audit/AuditLogWriter.php new file mode 100644 index 0000000..d467abb --- /dev/null +++ b/src/Module/Core/Infrastructure/Audit/AuditLogWriter.php @@ -0,0 +1,97 @@ + 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 $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 $data + * + * @return array + */ + private function stripSensitive(array $data): array + { + foreach (self::SENSITIVE_KEYS as $sensitiveKey) { + unset($data[$sensitiveKey]); + } + + return $data; + } +} diff --git a/src/Module/Core/Infrastructure/Audit/RequestIdProvider.php b/src/Module/Core/Infrastructure/Audit/RequestIdProvider.php new file mode 100644 index 0000000..b194f5a --- /dev/null +++ b/src/Module/Core/Infrastructure/Audit/RequestIdProvider.php @@ -0,0 +1,42 @@ +isMainRequest()) { + return; + } + + $this->requestId = Uuid::v4()->toRfc4122(); + } + + public function getRequestId(): ?string + { + return $this->requestId; + } +} diff --git a/src/Module/Core/Infrastructure/Doctrine/AuditListener.php b/src/Module/Core/Infrastructure/Doctrine/AuditListener.php new file mode 100644 index 0000000..aac32c6 --- /dev/null +++ b/src/Module/Core/Infrastructure/Doctrine/AuditListener.php @@ -0,0 +1,336 @@ +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 + */ + private array $auditableCache = []; + + /** + * Cache par FQCN : liste des noms de proprietes ignorees (#[AuditIgnore]). + * + * @var array> + */ + 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, 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 + */ + 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 + */ + 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 + */ + 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\\\(?[^\\\]+)\\\.+\\\(?[^\\\]+)$#', $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; + } +} diff --git a/src/Shared/Domain/Attribute/AuditIgnore.php b/src/Shared/Domain/Attribute/AuditIgnore.php new file mode 100644 index 0000000..18f046f --- /dev/null +++ b/src/Shared/Domain/Attribute/AuditIgnore.php @@ -0,0 +1,19 @@ + 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, + ]); + } + } +} diff --git a/tests/Module/Core/Infrastructure/Audit/AuditLogWriterTest.php b/tests/Module/Core/Infrastructure/Audit/AuditLogWriterTest.php new file mode 100644 index 0000000..9932540 --- /dev/null +++ b/tests/Module/Core/Infrastructure/Audit/AuditLogWriterTest.php @@ -0,0 +1,163 @@ +, 2: array} + * + * 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; + } +} diff --git a/tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php b/tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php new file mode 100644 index 0000000..523500b --- /dev/null +++ b/tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php @@ -0,0 +1,176 @@ + 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 + */ + private function fetchAuditRows(int $userId): array + { + /** @var list $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; + } +}