From fda03bd1f5385961da2fb296573f75186f0e9b56 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 19 Jun 2026 20:53:36 +0200 Subject: [PATCH] docs : add LST-61 audit log implementation plan --- .../plans/2026-06-19-lst-61-audit-log.md | 706 ++++++++++++++++++ 1 file changed, 706 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-19-lst-61-audit-log.md diff --git a/docs/superpowers/plans/2026-06-19-lst-61-audit-log.md b/docs/superpowers/plans/2026-06-19-lst-61-audit-log.md new file mode 100644 index 0000000..4593228 --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-lst-61-audit-log.md @@ -0,0 +1,706 @@ +# 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.