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.phpauto-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 surfeat/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 classesrc/Shared/Domain/Attribute/AuditIgnore.php— marqueur propriétésrc/Module/Core/Infrastructure/Audit/AuditLogWriter.php— écriture DBALauditsrc/Module/Core/Infrastructure/Audit/RequestIdProvider.php— UUID par requêtesrc/Module/Core/Infrastructure/Doctrine/AuditListener.php— capture onFlush/postFlushsrc/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.phpsrc/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.phpsrc/Module/Core/Application/DTO/AuditLogOutput.phpsrc/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.phpsrc/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.phpsrc/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.phpmigrations/Version20260619XXXXXX.php— tableaudit_logtests/Functional/Module/Core/AuditListenerTest.phptests/Functional/Module/Core/AuditLogApiTest.phpfrontend/modules/core/services/audit-logs.tsfrontend/components/admin/AdminAuditTab.vue
Modifiés :
config/packages/doctrine.yaml— connexionaudit+schema_filteraudit_logsrc/Module/Core/CoreModule.php— permissioncore.audit_log.viewsrc/Module/Core/Domain/Entity/User.php—#[Auditable]+#[AuditIgnore]password/apiTokensrc/Module/Core/Domain/Entity/Role.php—#[Auditable]src/Module/Core/Domain/Entity/Permission.php—#[Auditable]tests/Unit/Module/Core/CoreModuleTest.php— assert nouvelle permissionfrontend/pages/admin.vue— onglet Audit gatedcore.audit_log.viewfrontend/i18n/locales/fr.json— clésadmin.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 viaphp bin/console make:migrationpuis 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— restructurerconfig/packages/doctrine.yaml. Remplacer le blocdbalracine (connexion unique) par des connexions nommées, et propager ledbname_suffixde test aux deux connexions. Le blocormreste 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 litaudit_logvia la connexionaudit. (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). AjustermakeUser()aux champs NOT NULL réels de la tableuser. SiUserexige d'autres champs obligatoires (ex.createdAtinitialisé 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, namespaceApp\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 regexApp\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.phpsrc/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.phpsrc/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.phpsrc/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.phpsrc/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.phpsrc/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php
Adaptation pagination : Lesstime n'a pas de
itemsPerPage/maximum_items_per_pageexplicite dansapi_platform.yaml. Le provider utilisePagination::getPage()/getLimit()(défauts API Platform : 30/page). C'est acceptable. Conserver le clampmax(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. - admin authentifié :
-
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.viewen 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: Service —
frontend/modules/core/services/audit-logs.ts: fonctionfetchAuditLogs(params)viauseApi()(suivreroles.ts/permissions.tscréé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 parentityTypeetaction. Labels via i18naudit.entity.*etaudit.action.*. Reproduire le style deAdminRoleTab.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: ajouteradmin.audit.*(titre, colonnes, filtres) etaudit.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, commitdocs : 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.