# LST-61 (1.3) · Audit log — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Porter l'infrastructure d'audit de Starseed dans Lesstime : tracer create/update/delete des entités `#[Auditable]` dans une table append-only `audit_log`, exposée en lecture seule via `GET /api/audit-logs` (paginé + filtrable), avec une page de consultation front gated RBAC. **Architecture:** 4 couches indépendantes, additives (strangler) — (1) **marquage** déclaratif `#[Auditable]`/`#[AuditIgnore]` dans `src/Shared/Domain/Attribute/` ; (2) **capture** par un `AuditListener` Doctrine sur `onFlush`/`postFlush` (capture en mémoire puis écriture déphasée) ; (3) **écriture** via `AuditLogWriter` sur une connexion DBAL dédiée `audit` (hors transaction ORM, survit aux rollbacks) ; (4) **lecture API** via `AuditLogProvider` DBAL (pas d'entité ORM) + `DbalPaginator`. Front Nuxt : service + page consultation gated `core.audit_log.view`. **Tech Stack:** Symfony 8, API Platform 4, Doctrine ORM/DBAL, PostgreSQL 16, PHP 8.4, PHPUnit, symfony/uid (vendoré), Nuxt 4 / Vue 3 / Pinia / @nuxtjs/i18n. ## Global Constraints - **Aucune mention de Claude/Anthropic/IA** dans les écritures Git (commits, trailers, descriptions MR, merge). Messages factuels et techniques. - **Additif uniquement** : aucune migration destructive (pas de DROP/ALTER sur tables existantes en `up()`). - **PostgreSQL** : noms de colonnes toujours en minuscules snake_case dans le SQL brut. - **Code** : `declare(strict_types=1)`, PSR-12, patterns API Platform / Doctrine existants. Variables et commentaires en anglais. - **`config/reference.php`** auto-généré — NE JAMAIS committer. - Toujours lire un fichier avant de le modifier ; reproduire le style existant. - Branche : `feat/lst-61-audit-log` (empilée sur `feat/lst-57-rbac-fin`). - Tests Docker : `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`. --- ## File Structure **Créés :** - `src/Shared/Domain/Attribute/Auditable.php` — marqueur classe - `src/Shared/Domain/Attribute/AuditIgnore.php` — marqueur propriété - `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php` — écriture DBAL `audit` - `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php` — UUID par requête - `src/Module/Core/Infrastructure/Doctrine/AuditListener.php` — capture onFlush/postFlush - `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php` - `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php` - `src/Module/Core/Application/DTO/AuditLogOutput.php` - `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php` - `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php` - `src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php` - `migrations/Version20260619XXXXXX.php` — table `audit_log` - `tests/Functional/Module/Core/AuditListenerTest.php` - `tests/Functional/Module/Core/AuditLogApiTest.php` - `frontend/modules/core/services/audit-logs.ts` - `frontend/components/admin/AdminAuditTab.vue` **Modifiés :** - `config/packages/doctrine.yaml` — connexion `audit` + `schema_filter` audit_log - `src/Module/Core/CoreModule.php` — permission `core.audit_log.view` - `src/Module/Core/Domain/Entity/User.php` — `#[Auditable]` + `#[AuditIgnore]` password/apiToken - `src/Module/Core/Domain/Entity/Role.php` — `#[Auditable]` - `src/Module/Core/Domain/Entity/Permission.php` — `#[Auditable]` - `tests/Unit/Module/Core/CoreModuleTest.php` — assert nouvelle permission - `frontend/pages/admin.vue` — onglet Audit gated `core.audit_log.view` - `frontend/i18n/locales/fr.json` — clés `admin.audit.*` + `audit.entity.*` --- ## Task A: Marquage + table + connexion DBAL audit **Files:** - Create: `src/Shared/Domain/Attribute/Auditable.php`, `src/Shared/Domain/Attribute/AuditIgnore.php` - Create: `migrations/Version20260619XXXXXX.php` - Modify: `config/packages/doctrine.yaml` **Interfaces produced:** `App\Shared\Domain\Attribute\Auditable` (TARGET_CLASS), `App\Shared\Domain\Attribute\AuditIgnore` (TARGET_PROPERTY) ; service DBAL `doctrine.dbal.audit_connection` ; table `audit_log`. - [ ] **Step A1: Attributs** — créer les deux fichiers : ```php 20260619145109) : ```php 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(6) WITH TIME ZONE NOT NULL, ip_address VARCHAR(45) DEFAULT NULL, request_id VARCHAR(36) DEFAULT NULL, PRIMARY KEY(id) ) SQL); $this->addSql('CREATE INDEX idx_audit_entity_time ON audit_log (entity_type, entity_id, performed_at)'); $this->addSql('CREATE INDEX idx_audit_performer ON audit_log (performed_by, performed_at)'); $this->addSql('CREATE INDEX idx_audit_time ON audit_log (performed_at)'); $this->addSql("COMMENT ON COLUMN audit_log.entity_type IS 'Audited entity type, format module.Entity (e.g. core.User)'"); $this->addSql("COMMENT ON COLUMN audit_log.entity_id IS 'Audited entity identifier (int or composite key serialized)'"); $this->addSql("COMMENT ON COLUMN audit_log.action IS 'create|update|delete'"); $this->addSql("COMMENT ON COLUMN audit_log.changes IS 'JSON diff: {field:{old,new}} for update, full snapshot for create/delete'"); $this->addSql("COMMENT ON COLUMN audit_log.performed_by IS 'User identifier or system'"); $this->addSql("COMMENT ON COLUMN audit_log.request_id IS 'UUID shared by all audit rows of a single HTTP request (null in CLI)'"); } public function down(Schema $schema): void { $this->addSql('DROP TABLE audit_log'); } } ``` - [ ] **Step A3: Connexion DBAL `audit`** — restructurer `config/packages/doctrine.yaml`. Remplacer le bloc `dbal` racine (connexion unique) par des connexions nommées, et propager le `dbname_suffix` de test aux deux connexions. **Le bloc `orm` reste inchangé** (l'EM par défaut se lie à `default_connection`). Remplacer : ```yaml dbal: url: '%env(resolve:DATABASE_URL)%' # IMPORTANT: You MUST configure your server version, # either here or in the DATABASE_URL env var (see .env file) #server_version: '16' profiling_collect_backtrace: '%kernel.debug%' ``` par : ```yaml dbal: default_connection: default connections: # ORM uses `default`; AuditLogWriter uses `audit` (same DSN, separate # service) to write outside the ORM transaction so audit rows survive # an application-side rollback and avoid transactional entanglement. default: url: '%env(resolve:DATABASE_URL)%' profiling_collect_backtrace: '%kernel.debug%' # audit_log has no ORM entity (written via raw DBAL). Exclude it # from schema comparison so migrations:diff / schema:validate stay # clean. Creation/teardown stay driven by migrations. schema_filter: '~^(?!audit_log$).+~' audit: url: '%env(resolve:DATABASE_URL)%' ``` Et remplacer le bloc `when@test` : ```yaml when@test: doctrine: dbal: # "TEST_TOKEN" is typically set by ParaTest dbname_suffix: '_test%env(default::TEST_TOKEN)%' ``` par : ```yaml when@test: doctrine: dbal: # Propagate the _test suffix to BOTH connections: the audit # connection must write to the test DB, not the dev DB. connections: default: dbname_suffix: '_test%env(default::TEST_TOKEN)%' audit: dbname_suffix: '_test%env(default::TEST_TOKEN)%' ``` - [ ] **Step A4: Vérifier la non-régression** — la restructuration des connexions est le point sensible. Lancer la suite existante : ```bash docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit ``` Expected: 147 tests toujours verts (aucune régression liée au changement de connexions). - [ ] **Step A5: Appliquer la migration (dev + test)** : ```bash docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate -n docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate -n --env=test docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --env=test 2>&1 | grep -i "audit_log" || echo "OK: audit_log absent du diff (schema_filter actif)" ``` Expected: table créée, `audit_log` absente de tout diff généré. - [ ] **Step A6: Commit** ```bash git add src/Shared/Domain/Attribute config/packages/doctrine.yaml migrations/ git commit -m "feat(core) : add audit attributes, audit_log table and dedicated dbal connection" ``` --- ## Task B: AuditLogWriter + RequestIdProvider **Files:** - Create: `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php` - Create: `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php` **Interfaces produced:** `AuditLogWriter::log(string $entityType, string $entityId, string $action, array $changes): void` ; `RequestIdProvider::getRequestId(): ?string`. - [ ] **Step B1: RequestIdProvider** (verbatim Starseed) : ```php isMainRequest()) { return; } $this->requestId = Uuid::v4()->toRfc4122(); } public function getRequestId(): ?string { return $this->requestId; } } ``` - [ ] **Step B2: AuditLogWriter** (verbatim Starseed, connexion `audit`) : ```php keys always stripped from the `changes` payload */ private const array SENSITIVE_KEYS = ['password', 'plainPassword', 'apiToken', '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, ) { } /** * @param string $entityType Format "module.Entity" (e.g. "core.User") * @param string $entityId Entity id (int or serialized UUID) * @param string $action create|update|delete * @param array $changes JSON payload (sensitive keys stripped) */ 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(), ], [ 'id' => Types::GUID, 'changes' => Types::JSON, 'performed_at' => Types::DATETIMETZ_IMMUTABLE, ]); } /** * Recursively removes sensitive keys from the payload. * * @param array $data * * @return array */ private function stripSensitive(array $data): array { foreach ($data as $key => $value) { if (in_array($key, self::SENSITIVE_KEYS, true)) { unset($data[$key]); continue; } if (is_array($value)) { $data[$key] = $this->stripSensitive($value); } } return $data; } } ``` - [ ] **Step B3: Vérifier le câblage** (autowiring) : ```bash docker exec -t -u www-data php-lesstime-fpm php bin/console debug:container App\\Module\\Core\\Infrastructure\\Audit\\AuditLogWriter 2>&1 | head -20 ``` Expected: service trouvé, injection `doctrine.dbal.audit_connection` résolue. - [ ] **Step B4: Commit** ```bash git add src/Module/Core/Infrastructure/Audit/ git commit -m "feat(core) : add audit log writer and request id provider" ``` --- ## Task C: AuditListener + marquage des entités Core **Files:** - Create: `src/Module/Core/Infrastructure/Doctrine/AuditListener.php` - Modify: `src/Module/Core/Domain/Entity/User.php`, `Role.php`, `Permission.php` - Test: `tests/Functional/Module/Core/AuditListenerTest.php` **Interfaces consumed:** `AuditLogWriter`, attributs `Auditable`/`AuditIgnore`. - [ ] **Step C1: Écrire le test fonctionnel (échec attendu)** — `tests/Functional/Module/Core/AuditListenerTest.php`. Le test crée/modifie/supprime un User via l'EntityManager dans le kernel de test, puis lit `audit_log` via la connexion `audit`. (S'inspirer du style des tests fonctionnels existants — `RoleApiTest`, `UserRbacApiTest`.) ```php em = $container->get(EntityManagerInterface::class); $this->auditConnection = $container->get('doctrine.dbal.audit_connection'); // Clean slate for deterministic assertions. $this->auditConnection->executeStatement('DELETE FROM audit_log'); } public function testCreateUserIsAudited(): void { $user = $this->makeUser('audit_create_user'); $this->em->persist($user); $this->em->flush(); $rows = $this->fetchLogs('core.User', (string) $user->getId()); self::assertCount(1, $rows); self::assertSame('create', $rows[0]['action']); $changes = json_decode((string) $rows[0]['changes'], true); self::assertArrayHasKey('username', $changes); self::assertArrayNotHasKey('password', $changes, 'password must be excluded via #[AuditIgnore]'); self::assertArrayNotHasKey('apiToken', $changes, 'apiToken must be excluded via #[AuditIgnore]'); } public function testUpdateUserIsAuditedWithDiff(): void { $user = $this->makeUser('audit_update_user'); $this->em->persist($user); $this->em->flush(); $this->auditConnection->executeStatement('DELETE FROM audit_log'); $user->setFirstName('Changed'); $this->em->flush(); $rows = $this->fetchLogs('core.User', (string) $user->getId()); self::assertCount(1, $rows); self::assertSame('update', $rows[0]['action']); $changes = json_decode((string) $rows[0]['changes'], true); self::assertArrayHasKey('firstName', $changes); self::assertSame('Changed', $changes['firstName']['new']); } public function testDeleteUserIsAudited(): void { $user = $this->makeUser('audit_delete_user'); $this->em->persist($user); $this->em->flush(); $id = (string) $user->getId(); $this->auditConnection->executeStatement('DELETE FROM audit_log'); $this->em->remove($user); $this->em->flush(); $rows = $this->fetchLogs('core.User', $id); self::assertCount(1, $rows); self::assertSame('delete', $rows[0]['action']); } private function makeUser(string $username): User { $user = new User(); $user->setUsername($username); $user->setPassword('hashed-secret'); $user->setRoles(['ROLE_USER']); return $user; } /** * @return list> */ private function fetchLogs(string $entityType, string $entityId): array { return $this->auditConnection->fetchAllAssociative( 'SELECT action, changes FROM audit_log WHERE entity_type = :t AND entity_id = :id ORDER BY performed_at ASC', ['t' => $entityType, 'id' => $entityId], ); } protected function tearDown(): void { parent::tearDown(); unset($this->em, $this->auditConnection); } } ``` > **Note adaptation :** vérifier la signature réelle de `User` (setters disponibles : `setUsername`, `setPassword`, `setRoles`, `setFirstName`). Ajuster `makeUser()` aux champs NOT NULL réels de la table `user`. Si `User` exige d'autres champs obligatoires (ex. `createdAt` initialisé au constructeur — déjà le cas), ne rien ajouter. - [ ] **Step C2: Run le test → échec** (listener absent, entités non marquées) : ```bash docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/AuditListenerTest.php ``` Expected: FAIL. - [ ] **Step C3: Créer `AuditListener`** (verbatim Starseed, namespace `App\Module\Core\Infrastructure\Doctrine`). Copier intégralement le listener fourni dans le rapport Starseed (onFlush capture + postFlush écriture, swap-and-clear, gestion collections, snapshot create/delete, buildUpdateChanges, formatEntityType regex `App\Module\\...\`, caches Auditable/AuditIgnore). **Ne rien simplifier.** - [ ] **Step C4: Marquer les entités Core.** `src/Module/Core/Domain/Entity/User.php` — ajouter import + attribut classe + `#[AuditIgnore]` sur `password` et `apiToken` : ```php use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Attribute\AuditIgnore; ``` ```php #[Auditable] #[ORM\Entity(repositoryClass: DoctrineUserRepository::class)] #[ORM\Table(name: '`user`')] class User implements ... ``` Sur la propriété `password` (ligne ~89-90) et `apiToken` (ligne ~99-100), ajouter `#[AuditIgnore]` au-dessus de la ligne `private ?string $password = null;` / `private ?string $apiToken = null;`. `src/Module/Core/Domain/Entity/Role.php` — ajouter `use App\Shared\Domain\Attribute\Auditable;` et `#[Auditable]` au-dessus de `#[ORM\Entity...]`. `src/Module/Core/Domain/Entity/Permission.php` — idem `#[Auditable]`. - [ ] **Step C5: Run le test → succès** : ```bash docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/AuditListenerTest.php ``` Expected: PASS (3 tests). - [ ] **Step C6: Suite complète + cs-fixer** : ```bash docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit make php-cs-fixer-allow-risky ``` Expected: tout vert. - [ ] **Step C7: Commit** ```bash git add src/Module/Core/Infrastructure/Doctrine/AuditListener.php src/Module/Core/Domain/Entity/ tests/Functional/Module/Core/AuditListenerTest.php git commit -m "feat(core) : add doctrine audit listener and mark core entities auditable" ``` --- ## Task D: API de lecture `/api/audit-logs` + permission **Files:** - Create: `AuditLogOutput.php`, `DbalPaginator.php`, `AuditLogProvider.php`, `AuditLogResource.php`, `AuditLogEntityTypesResource.php`, `AuditLogEntityTypesProvider.php` - Modify: `src/Module/Core/CoreModule.php` (permission), `tests/Unit/Module/Core/CoreModuleTest.php` - Test: `tests/Functional/Module/Core/AuditLogApiTest.php` **Interfaces consumed:** table `audit_log`, connexion `doctrine.dbal.default_connection`, permission `core.audit_log.view`. - [ ] **Step D1: Permission** — ajouter dans `CoreModule::permissions()` : ```php ['code' => 'core.audit_log.view', 'label' => 'Consulter le journal d\'audit'], ``` Mettre à jour `tests/Unit/Module/Core/CoreModuleTest.php` pour asserter la présence de ce code (la liste passe à 6 permissions). - [ ] **Step D2: DTO + Paginator + Providers + Resources** — créer les 6 fichiers verbatim depuis le rapport Starseed : - `src/Module/Core/Application/DTO/AuditLogOutput.php` - `src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php` - `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php` - `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php` - `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php` - `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php` **Adaptation pagination :** Lesstime n'a pas de `itemsPerPage`/`maximum_items_per_page` explicite dans `api_platform.yaml`. Le provider utilise `Pagination::getPage()`/`getLimit()` (défauts API Platform : 30/page). C'est acceptable. Conserver le clamp `max(1, page)`. - [ ] **Step D3: Écrire le test API (échec attendu)** — `tests/Functional/Module/Core/AuditLogApiTest.php`. S'aligner sur le helper d'auth des tests existants (login admin/admin via cookie JWT, cf. `RoleApiTest`). Tests : - admin authentifié : `GET /api/audit-logs` → 200, structure hydra paginée. - filtre `?action=update` → ne renvoie que des updates. - filtre `?entity_type=core.User`. - `?action=bogus` → 400. - utilisateur sans permission (alice) : 403. - non authentifié : 401. Préparer des données : créer/modifier un User via l'EM avant les assertions (le listener écrit), OU insérer directement des lignes via la connexion `audit`. - [ ] **Step D4: Run → échec, puis vérifier la route** : ```bash docker exec -t -u www-data php-lesstime-fpm php bin/console debug:router 2>&1 | grep -i audit docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/AuditLogApiTest.php ``` Expected: routes `/api/audit-logs`, `/api/audit-logs/{id}`, `/api/audit-log-entity-types` présentes ; test passe une fois les providers branchés. - [ ] **Step D5: sync-permissions** (enregistre `core.audit_log.view` en base dev + test) : ```bash docker exec -t -u www-data php-lesstime-fpm php bin/console app:sync-permissions docker exec -t -u www-data php-lesstime-fpm php bin/console app:sync-permissions --env=test ``` - [ ] **Step D6: Suite complète + cs-fixer** ```bash docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit make php-cs-fixer-allow-risky ``` - [ ] **Step D7: Commit** ```bash git add src/Module/Core/ tests/ git commit -m "feat(core) : expose read-only audit-logs api with dbal provider and pagination" ``` --- ## Task E: Front — page consultation gated RBAC **Files:** - Create: `frontend/modules/core/services/audit-logs.ts`, `frontend/components/admin/AdminAuditTab.vue` - Modify: `frontend/pages/admin.vue`, `frontend/i18n/locales/fr.json` **Interfaces consumed:** `GET /api/audit-logs`, composable `usePermissions` (livré en 1.2), pattern onglet admin (cf. `AdminRoleTab.vue` créé en 1.2). - [ ] **Step E1: Service** — `frontend/modules/core/services/audit-logs.ts` : fonction `fetchAuditLogs(params)` via `useApi()` (suivre `roles.ts`/`permissions.ts` créés en 1.2). Types : `AuditLogItem { id, entityType, entityId, action, changes, performedBy, performedAt, ipAddress, requestId }`. - [ ] **Step E2: Composant onglet** — `frontend/components/admin/AdminAuditTab.vue` : tableau paginé (colonnes date, utilisateur, type d'entité, action, id), filtre par `entityType` et `action`. Labels via i18n `audit.entity.*` et `audit.action.*`. Reproduire le style de `AdminRoleTab.vue`. - [ ] **Step E3: Onglet dans admin.vue** — ajouter un onglet « Audit » gated `can('core.audit_log.view')` (suivre le gating de l'onglet rôles ajouté en 1.2). - [ ] **Step E4: i18n** — `frontend/i18n/locales/fr.json` : ajouter `admin.audit.*` (titre, colonnes, filtres) et `audit.entity.core.User` = « Utilisateur », `audit.entity.core.Role` = « Rôle », `audit.entity.core.Permission` = « Permission » ; `audit.action.create/update/delete`. - [ ] **Step E5: Vérifier la route déterministe (SPA)** : ```bash cd frontend && npx nuxt build 2>&1 | tail -5 grep -o 'name:"admin"' .output/server/chunks/build/client.precomputed.mjs | head -1 ``` Expected: build OK (la page admin reste enregistrée). - [ ] **Step E6: Commit** ```bash git add frontend/ git commit -m "feat(core) : add audit log consultation tab in admin gated by permission" ``` --- ## Task F: Validation finale + statut - [ ] **Step F1: Suite complète verte + login fumée** ```bash docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit ``` Vérifier login admin → 204 + `GET /api/me` 200 + `GET /api/audit-logs` 200 (cURL ou via test). - [ ] **Step F2: migrations:diff propre** (audit_log absente du diff) : ```bash docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --env=test 2>&1 | grep -ci audit_log ``` Expected: 0. - [ ] **Step F3: Learnings** — append session #61 à `.claude/skills/ticket-executor/LEARNINGS.md`, commit `docs : log LST-61 audit log session learnings`. - [ ] **Step F4: Push branche + MR empilée sur #57** (Gitea, base `feat/lst-57-rbac-fin`), draft puis un-draft via API si voulu. - [ ] **Step F5: Ticket #61 (id 647) → « En attente de validation » (statut 4)**, stopper le timer, informer l'utilisateur. --- ## Self-Review (couverture spec) | Critère d'acceptation | Tâche | |---|---| | CRUD des entités `#[Auditable]` tracé | C (listener + test create/update/delete) | | Endpoint `/api/audit-logs` paginé/filtrable | D (provider DBAL + DbalPaginator + filtres) | | `make test` vert, aucune migration destructive | A (migration additive), C/D/F (suite) | | `#[Auditable]`/`#[AuditIgnore]` dans Shared | A1 | | Table `audit_log` (qui/quoi/quand/diff/requestId) + COMMENT | A2 | | `#[AuditIgnore]` champs sensibles (password, apiToken) | C4 + B2 blacklist | | Front consultation + i18n `audit.entity.*` gated RBAC | E | **Décision de scope :** `#[Auditable]` posé sur les **entités migrées** (User, Role, Permission) conformément au libellé du ticket. Les entités métier legacy (`src/Entity/*`) ne sont pas marquées ici — elles le seront lors de leur migration en modules (phases 2.x+). L'infra est prête à les auditer sans modification dès qu'elles portent l'attribut.