# 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
<?php
// src/Shared/Domain/Attribute/Auditable.php
declare(strict_types=1);

namespace App\Shared\Domain\Attribute;

use Attribute;

/**
 * Marker placed on a Doctrine entity to enable audit tracking.
 *
 * Located in Shared (not Core) so every module can use it without a
 * circular dependency on Core. Any migrated business entity that should be
 * traced carries this attribute, with #[AuditIgnore] on sensitive fields.
 */
#[Attribute(Attribute::TARGET_CLASS)]
final class Auditable
{
}
```

```php
<?php
// src/Shared/Domain/Attribute/AuditIgnore.php
declare(strict_types=1);

namespace App\Shared\Domain\Attribute;

use Attribute;

/**
 * Marker placed on an entity property to exclude it from audit tracking.
 *
 * Typical use: sensitive fields (password, apiToken). The AuditLogWriter also
 * carries an exact-match blacklist on the most dangerous names as
 * defense-in-depth, but the base rule is to annotate explicitly here.
 */
#[Attribute(Attribute::TARGET_PROPERTY)]
final class AuditIgnore
{
}
```

- [ ] **Step A2: Migration** — créer `migrations/Version20260619XXXXXX.php` (timestamp réel via `php bin/console make:migration` puis remplacer le contenu, OU horodatage manuel cohérent > 20260619145109) :

```php
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
 * Audit log (LST-61) : append-only `audit_log` table.
 *
 * Not managed by Doctrine ORM (no entity). Written via raw DBAL by the
 * AuditLogWriter on a dedicated `audit` connection to avoid re-entrant
 * flushes from the Doctrine listener. Columns are lowercase snake_case.
 * Additive only — no DROP/ALTER on existing tables.
 */
final class Version20260619XXXXXX extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Audit log: create append-only audit_log table + indexes (additive)';
    }

    public function up(Schema $schema): void
    {
        $this->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
<?php

declare(strict_types=1);

namespace App\Module\Core\Infrastructure\Audit;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Uid\Uuid;

/**
 * Provides an HTTP request identifier (UUID v4) shared by every audit row
 * produced during a single main request. Null in CLI (fixtures, batch).
 */
final class RequestIdProvider
{
    private ?string $requestId = null;

    #[AsEventListener(event: 'kernel.request')]
    public function onKernelRequest(RequestEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }

        $this->requestId = Uuid::v4()->toRfc4122();
    }

    public function getRequestId(): ?string
    {
        return $this->requestId;
    }
}
```

- [ ] **Step B2: AuditLogWriter** (verbatim Starseed, connexion `audit`) :

```php
<?php

declare(strict_types=1);

namespace App\Module\Core\Infrastructure\Audit;

use DateTimeImmutable;
use DateTimeZone;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Types\Types;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Uid\Uuid;

/**
 * Low-level service responsible for writing into the `audit_log` table.
 *
 * Uses a dedicated `audit` DBAL connection (same DSN as `default`) to write
 * outside the ORM transaction: audit rows survive an application-side
 * rollback and avoid transactional entanglement in batch (fixtures).
 *
 * Sensitive keys are stripped in defense-in-depth even when entities already
 * declare those properties #[AuditIgnore]. SQL failures are swallowed by the
 * caller (AuditListener wraps log() in try/catch) — audit must never crash a
 * business flow.
 */
final class AuditLogWriter
{
    /** @var list<string> 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<string, mixed> $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<string, mixed> $data
     *
     * @return array<string, mixed>
     */
    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
<?php

declare(strict_types=1);

namespace App\Tests\Functional\Module\Core;

use App\Module\Core\Domain\Entity\User;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

/**
 * @internal
 */
final class AuditListenerTest extends KernelTestCase
{
    private EntityManagerInterface $em;
    private Connection $auditConnection;

    protected function setUp(): void
    {
        self::bootKernel();
        $container = self::getContainer();
        $this->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<array<string, mixed>>
     */
    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\<module>\...\<Entity>`, 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.
