Files
Lesstime/docs/superpowers/plans/2026-06-19-lst-61-audit-log.md
T
2026-06-19 20:53:36 +02:00

30 KiB

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
// 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
// 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

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 :

    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 :

    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 :

when@test:
    doctrine:
        dbal:
            # "TEST_TOKEN" is typically set by ParaTest
            dbname_suffix: '_test%env(default::TEST_TOKEN)%'

par :

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 :
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) :
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
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

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

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) :
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
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

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) :
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 :

use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
#[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 :
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 :
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
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() :
['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 :

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) :
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
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
make php-cs-fixer-allow-risky
  • Step D7: Commit
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: Servicefrontend/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 ongletfrontend/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: i18nfrontend/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) :

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
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
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) :
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.