64 KiB
RBAC fin (LST-57 / 1.2) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Porter le RBAC fin de Starseed dans le Module Core de Lesstime : permissions module.resource[.sub].action, entités Role/Permission, commande de synchronisation, PermissionVoter, sidebar filtrée par permission, et gestion front des rôles.
Architecture: RBAC additif par-dessus l'auth Symfony existante (cf. Décision 1). On garde la colonne JSON roles + ROLE_ADMIN/ROLE_USER (login/JWT/MCP/sidebar #62 inchangés ; ROLE_ADMIN = bypass du voter) et on ajoute la couche RBAC fine : Role/Permission (Module Core), relations rbacRoles/directPermissions sur User, getEffectivePermissions(), PermissionVoter, app:sync-permissions, app:seed-rbac, sidebar gated par permission, front usePermissions.
Tech Stack: PHP 8.4 / Symfony 8 / API Platform 4 / Doctrine ORM / PostgreSQL 16 — Nuxt 4 / Vue 3 / Pinia / TypeScript. Tests : PHPUnit 13. Docker : php-lesstime-fpm (user www-data), nginx port 8082, PG port 5435.
Global Constraints
declare(strict_types=1)en tête de tout fichier PHP. Symfony + PSR-12, hook pre-commit php-cs-fixer (ne pas lutter contre le reformat).- Namespaces : back
App\Module\Core\...,App\Shared\.... Front layerfrontend/modules/core/. - Zéro régression auth : après chaque phase touchant la sécurité (C, D, F), exécuter le bloc « Vérification login » ci-dessous →
login=204,/api/me=200,/_mcp=200. - Migration additive uniquement :
CREATE TABLEdes tables RBAC ; aucunDROP/ALTERdestructif suruser/roles.doctrine:migrations:diffaprès doit être vide (hors dérive préexistantemessenger_messages). - PostgreSQL : noms de colonnes en minuscules dans le SQL brut ;
roles::text LIKEpour les colonnes JSON. config/reference.phpest auto-généré : ne jamais le committer. Untracked.codex,bulettins/: ignorer.- Aucune mention de Claude/IA dans les commits.
- Commits :
<type>(core) : <message>(espace autour du:). - Tests existants au départ : 120 verts. Chaque phase ajoute ses tests et garde l'ensemble vert.
Vérification login (à exécuter après chaque phase back touchant User/sécurité)
curl -s -i -X POST http://localhost:8082/api/login_check -H "Content-Type: application/json" -d '{"username":"alice","password":"alice"}' -D /tmp/h.txt -o /dev/null -w "login=%{http_code}\n"
BEARER=$(grep -i 'set-cookie: BEARER' /tmp/h.txt | sed -E 's/.*BEARER=([^;]+);.*/\1/')
curl -s -o /dev/null -w "me=%{http_code}\n" http://localhost:8082/api/me -H "Cookie: BEARER=$BEARER"
curl -s -o /dev/null -w "mcp=%{http_code}\n" -X POST http://localhost:8082/_mcp -H "Authorization: Bearer dev-mcp-token-for-testing-only-do-not-use-in-production" -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"c","version":"1"}}}'
Attendu : login=204, me=200, mcp=200.
Décisions de conception (actées, à valider PO a posteriori)
- RBAC additif,
ROLE_ADMIN= bypass (PAS de colonneis_admin) — divergence assumée vs Starseed (qui a supprimé la colonne JSONrolesau profit deis_admin). Justification : login/JWT,security.yaml(role_hierarchy,app_user_provider), gate sidebar #62 (roles: [ROLE_ADMIN]), MCPapiTokenreposent tous surgetRoles()/rolesJSON ; les réécrire = régression auth à haut risque pour zéro bénéfice AC. On gardegetRoles()tel quel ; lePermissionVoterbypass siin_array('ROLE_ADMIN', $user->getRoles()). Migration future versis_adminpossible si le PO le souhaite. user_permission(directPermissions) inclus — fidélité Starseed : un user peut recevoir des permissions directes en plus de ses rôles.getEffectivePermissions()= union(rôles.permissions, directPermissions).- Gestion de
ROLE_ADMINreste sur le PATCH user existant (roles), PAS sur l'endpoint RBAC — l'endpoint/api/users/{id}/rbacne gère querbacRoles+directPermissions. Pas de gardes « dernier admin »/« auto-suicide » (elles concernentis_admin, absent ici) ; on conserve uniquement la garde anti-écrasement des collections (defense in depth). - Pas de rôles métier seedés en 1.2 — seules les permissions
core.*existent (les modules métier arrivent en 2.x).app:seed-rbaccrée les rôles systèmeadminetuser(isSystem=true) sans matrice métier. Chaque module métier ajoutera ses permissions + (optionnel) ses rôles quand il sera livré. - Pas de
Sites— Lesstime n'a pas de notion de site : on retire toutes les gardes/relationssites.*,currentSite,bypass_scopedu portage Starseed. - Entités
Role/Permissionsans Timestampable/Blamable — alignées sur Starseed (métadonnées RBAC pures).
Phase A — Domaine RBAC : entités, relations, migration
Task 1: Entités Permission + Role + relations User + repositories + contrat
Files:
- Create:
src/Module/Core/Domain/Entity/Permission.php - Create:
src/Module/Core/Domain/Entity/Role.php - Create:
src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php - Create:
src/Module/Core/Domain/Repository/RoleRepositoryInterface.php - Create:
src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php - Create:
src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php - Modify:
src/Module/Core/Domain/Entity/User.php(relationsrbacRoles+directPermissions+getEffectivePermissions()) - Modify:
src/Shared/Domain/Contract/UserInterface.php(ajoutgetEffectivePermissions()) - Modify:
config/services.yaml(alias repositories) - Create:
tests/Unit/Module/Core/Domain/Entity/PermissionTest.php - Create:
tests/Unit/Module/Core/Domain/Entity/RoleTest.php - Modify:
tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php(stubgetEffectivePermissions()dans l'anonyme implémentantUserInterface)
Interfaces:
-
Produces :
Permission { getId, getCode, getLabel, getModule, isOrphan, markOrphan(), revive(label, module), updateMetadata(label, module) };Role { getId, getCode, getLabel, getDescription, isSystem, getPermissions(): Collection, addPermission(Permission), removePermission(Permission), ensureDeletable() };User::getEffectivePermissions(): list<string>,User::getRbacRoles(): Collection,addRbacRole/removeRbacRole,getDirectPermissions(): Collection,addDirectPermission/removeDirectPermission. -
Repositories :
PermissionRepositoryInterface { findById(int): ?Permission, findByCode(string): ?Permission, findAll(): list<Permission>, findAllCodes(): list<string>, save(Permission): void };RoleRepositoryInterface { findById(int): ?Role, findByCode(string): ?Role, findAll(): list<Role>, save(Role): void }. -
Step 1: Écrire les tests unitaires des entités
tests/Unit/Module/Core/Domain/Entity/PermissionTest.php :
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Module\Core\Domain\Entity;
use App\Module\Core\Domain\Entity\Permission;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class PermissionTest extends TestCase
{
public function testValidConstruction(): void
{
$p = new Permission('core.users.view', 'Voir les utilisateurs', 'core');
self::assertSame('core.users.view', $p->getCode());
self::assertSame('Voir les utilisateurs', $p->getLabel());
self::assertSame('core', $p->getModule());
self::assertFalse($p->isOrphan());
}
public function testCodeMustContainADot(): void
{
$this->expectException(\InvalidArgumentException::class);
new Permission('coreusersview', 'x', 'core');
}
public function testCodeCannotBeEmpty(): void
{
$this->expectException(\InvalidArgumentException::class);
new Permission('', 'x', 'core');
}
public function testLabelCannotBeEmpty(): void
{
$this->expectException(\InvalidArgumentException::class);
new Permission('core.users.view', '', 'core');
}
public function testModuleCannotBeEmpty(): void
{
$this->expectException(\InvalidArgumentException::class);
new Permission('core.users.view', 'x', '');
}
public function testMarkOrphanAndRevive(): void
{
$p = new Permission('core.users.view', 'Voir', 'core');
$p->markOrphan();
self::assertTrue($p->isOrphan());
$p->revive('Voir maj', 'core');
self::assertFalse($p->isOrphan());
self::assertSame('Voir maj', $p->getLabel());
}
}
tests/Unit/Module/Core/Domain/Entity/RoleTest.php :
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Module\Core\Domain\Entity;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Exception\SystemRoleDeletionException;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class RoleTest extends TestCase
{
public function testValidConstruction(): void
{
$r = new Role('bureau', 'Bureau');
self::assertSame('bureau', $r->getCode());
self::assertSame('Bureau', $r->getLabel());
self::assertFalse($r->isSystem());
self::assertCount(0, $r->getPermissions());
}
public function testCodeMustBeSnakeCase(): void
{
$this->expectException(\InvalidArgumentException::class);
new Role('Bureau Commercial', 'x');
}
public function testAddRemovePermission(): void
{
$r = new Role('bureau', 'Bureau');
$p = new Permission('core.users.view', 'Voir', 'core');
$r->addPermission($p);
self::assertCount(1, $r->getPermissions());
$r->addPermission($p); // idempotent
self::assertCount(1, $r->getPermissions());
$r->removePermission($p);
self::assertCount(0, $r->getPermissions());
}
public function testSystemRoleCannotBeDeleted(): void
{
$r = new Role('admin', 'Administrateur', null, true);
$this->expectException(SystemRoleDeletionException::class);
$r->ensureDeletable();
}
public function testNonSystemRoleIsDeletable(): void
{
$r = new Role('bureau', 'Bureau');
$r->ensureDeletable();
self::assertFalse($r->isSystem());
}
}
- Step 2: Lancer les tests, vérifier l'échec
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Module/Core/Domain/Entity/
Expected: FAIL (classes inexistantes).
- Step 3: Créer l'exception
src/Module/Core/Domain/Exception/SystemRoleDeletionException.php :
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Exception;
final class SystemRoleDeletionException extends \DomainException
{
public function __construct(string $code)
{
parent::__construct(sprintf('Le rôle système "%s" ne peut pas être supprimé.', $code));
}
}
- Step 4: Créer
Permission
src/Module/Core/Domain/Entity/Permission.php :
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)]
#[ORM\Table(name: 'permission')]
#[ORM\Index(name: 'idx_permission_module', columns: ['module'])]
#[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])]
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
],
normalizationContext: ['groups' => ['permission:read']],
security: "is_granted('core.permissions.view') or is_granted('core.users.manage') or is_granted('core.roles.manage')",
)]
class Permission
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['permission:read', 'role:read'])]
private ?int $id = null;
#[ORM\Column(length: 255, unique: true)]
#[Groups(['permission:read', 'role:read'])]
private string $code;
#[ORM\Column(length: 255)]
#[Groups(['permission:read', 'role:read'])]
private string $label;
#[ORM\Column(length: 100)]
#[Groups(['permission:read', 'role:read'])]
private string $module;
#[ORM\Column]
#[Groups(['permission:read'])]
private bool $orphan = false;
public function __construct(string $code, string $label, string $module)
{
$code = trim($code);
$label = trim($label);
$module = trim($module);
if ('' === $code || !str_contains($code, '.')) {
throw new \InvalidArgumentException(sprintf('Code de permission invalide : "%s" (attendu module.resource.action).', $code));
}
if ('' === $label) {
throw new \InvalidArgumentException('Le libellé de permission ne peut pas être vide.');
}
if ('' === $module) {
throw new \InvalidArgumentException('Le module de permission ne peut pas être vide.');
}
$this->code = $code;
$this->label = $label;
$this->module = $module;
}
public function getId(): ?int
{
return $this->id;
}
public function getCode(): string
{
return $this->code;
}
public function getLabel(): string
{
return $this->label;
}
public function getModule(): string
{
return $this->module;
}
public function isOrphan(): bool
{
return $this->orphan;
}
public function markOrphan(): void
{
$this->orphan = true;
}
public function revive(string $label, string $module): void
{
$this->orphan = false;
$this->updateMetadata($label, $module);
}
public function updateMetadata(string $label, string $module): void
{
$this->label = $label;
$this->module = $module;
}
}
- Step 5: Créer
Role
src/Module/Core/Domain/Entity/Role.php :
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Core\Domain\Exception\SystemRoleDeletionException;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor;
use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
#[ORM\Table(name: '`role`')]
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('core.roles.view')"),
new Get(security: "is_granted('core.roles.view')"),
new Post(security: "is_granted('core.roles.manage')", processor: RoleProcessor::class),
new Patch(security: "is_granted('core.roles.manage')", processor: RoleProcessor::class),
new Delete(security: "is_granted('core.roles.manage')", processor: RoleProcessor::class),
],
normalizationContext: ['groups' => ['role:read']],
denormalizationContext: ['groups' => ['role:write']],
)]
class Role
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['role:read'])]
private ?int $id = null;
#[ORM\Column(length: 100, unique: true)]
#[Groups(['role:read', 'role:write'])]
private string $code;
#[ORM\Column(length: 255)]
#[Groups(['role:read', 'role:write'])]
private string $label;
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['role:read', 'role:write'])]
private ?string $description;
#[ORM\Column(name: 'is_system')]
#[Groups(['role:read'])]
private bool $isSystem;
/**
* @var Collection<int, Permission>
*/
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'role_permission')]
#[Groups(['role:read', 'role:write'])]
private Collection $permissions;
public function __construct(string $code, string $label, ?string $description = null, bool $isSystem = false)
{
if (1 !== preg_match('/^[a-z][a-z0-9_]*$/', $code)) {
throw new \InvalidArgumentException(sprintf('Code de rôle invalide : "%s" (attendu snake_case).', $code));
}
if ('' === trim($label)) {
throw new \InvalidArgumentException('Le libellé de rôle ne peut pas être vide.');
}
$this->code = $code;
$this->label = $label;
$this->description = $description;
$this->isSystem = $isSystem;
$this->permissions = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getCode(): string
{
return $this->code;
}
public function getLabel(): string
{
return $this->label;
}
public function setLabel(string $label): void
{
$this->label = $label;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): void
{
$this->description = $description;
}
public function isSystem(): bool
{
return $this->isSystem;
}
/**
* @return Collection<int, Permission>
*/
public function getPermissions(): Collection
{
return $this->permissions;
}
public function addPermission(Permission $permission): void
{
if (!$this->permissions->contains($permission)) {
$this->permissions->add($permission);
}
}
public function removePermission(Permission $permission): void
{
$this->permissions->removeElement($permission);
}
public function ensureDeletable(): void
{
if ($this->isSystem) {
throw new SystemRoleDeletionException($this->code);
}
}
}
- Step 6: Ajouter les relations RBAC +
getEffectivePermissions()àUser
Dans src/Module/Core/Domain/Entity/User.php : ajouter les imports use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; (si absents) et, dans la classe :
/**
* @var Collection<int, Role>
*/
#[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_role')]
#[Groups(['user:rbac:read'])]
private Collection $rbacRoles;
/**
* @var Collection<int, Permission>
*/
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_permission')]
#[Groups(['user:rbac:read'])]
private Collection $directPermissions;
⚠️ Initialiser les deux collections dans le constructeur de
User(s'il en existe un ; sinon en ajouter unpublic function __construct() { $this->rbacRoles = new ArrayCollection(); $this->directPermissions = new ArrayCollection(); }. Vérifier d'abord s'il y a déjà un constructeur —createdAtest posé ailleurs ? Lire l'entité. Si pas de constructeur, en créer un n'initialisant QUE ces deux collections).
Ajouter les méthodes :
/**
* @return Collection<int, Role>
*/
public function getRbacRoles(): Collection
{
return $this->rbacRoles;
}
public function addRbacRole(Role $role): void
{
if (!$this->rbacRoles->contains($role)) {
$this->rbacRoles->add($role);
}
}
public function removeRbacRole(Role $role): void
{
$this->rbacRoles->removeElement($role);
}
/**
* @return Collection<int, Permission>
*/
public function getDirectPermissions(): Collection
{
return $this->directPermissions;
}
public function addDirectPermission(Permission $permission): void
{
if (!$this->directPermissions->contains($permission)) {
$this->directPermissions->add($permission);
}
}
public function removeDirectPermission(Permission $permission): void
{
$this->directPermissions->removeElement($permission);
}
/**
* Permissions effectives = union (rôles RBAC → permissions) ∪ (permissions directes), triée, dédupliquée.
*
* @return list<string>
*/
#[Groups(['me:read', 'user:rbac:read'])]
public function getEffectivePermissions(): array
{
$codes = [];
foreach ($this->rbacRoles as $role) {
foreach ($role->getPermissions() as $permission) {
$codes[$permission->getCode()] = true;
}
}
foreach ($this->directPermissions as $permission) {
$codes[$permission->getCode()] = true;
}
$keys = array_keys($codes);
sort($keys);
return $keys;
}
Ajouter les imports use App\Module\Core\Domain\Entity\Role; n'est pas nécessaire (même namespace) ; Permission non plus. Vérifier la présence de use Symfony\Component\Serializer\Annotation\Groups; (déjà là vu les Groups existants).
- Step 7: Enrichir le contrat
UserInterface
Dans src/Shared/Domain/Contract/UserInterface.php, ajouter :
/** @return list<string> */
public function getEffectivePermissions(): array;
Puis ajouter le stub dans l'anonyme du test tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php qui implémente UserInterface :
public function getEffectivePermissions(): array
{
return [];
}
- Step 8: Créer les repositories + alias services
src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php :
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Repository;
use App\Module\Core\Domain\Entity\Permission;
interface PermissionRepositoryInterface
{
public function findById(int $id): ?Permission;
public function findByCode(string $code): ?Permission;
/** @return list<Permission> */
public function findAll(): array;
/** @return list<string> */
public function findAllCodes(): array;
public function save(Permission $permission): void;
}
src/Module/Core/Domain/Repository/RoleRepositoryInterface.php :
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Repository;
use App\Module\Core\Domain\Entity\Role;
interface RoleRepositoryInterface
{
public function findById(int $id): ?Role;
public function findByCode(string $code): ?Role;
/** @return list<Role> */
public function findAll(): array;
public function save(Role $role): void;
}
src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php :
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Permission>
*/
final class DoctrinePermissionRepository extends ServiceEntityRepository implements PermissionRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Permission::class);
}
public function findById(int $id): ?Permission
{
return $this->find($id);
}
public function findByCode(string $code): ?Permission
{
return $this->findOneBy(['code' => $code]);
}
/** @return list<Permission> */
public function findAll(): array
{
return array_values($this->findBy([]));
}
/** @return list<string> */
public function findAllCodes(): array
{
/** @var list<array{code: string}> $rows */
$rows = $this->createQueryBuilder('p')->select('p.code')->getQuery()->getArrayResult();
return array_map(static fn (array $r): string => $r['code'], $rows);
}
public function save(Permission $permission): void
{
$this->getEntityManager()->persist($permission);
}
}
src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php :
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Role>
*/
final class DoctrineRoleRepository extends ServiceEntityRepository implements RoleRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Role::class);
}
public function findById(int $id): ?Role
{
return $this->find($id);
}
public function findByCode(string $code): ?Role
{
return $this->findOneBy(['code' => $code]);
}
/** @return list<Role> */
public function findAll(): array
{
return array_values($this->findBy([]));
}
public function save(Role $role): void
{
$this->getEntityManager()->persist($role);
}
}
Dans config/services.yaml, sous les alias existants (à côté de UserRepositoryInterface) :
App\Module\Core\Domain\Repository\PermissionRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository'
App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository'
- Step 9: Lancer les tests unitaires entités
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Module/Core/Domain/Entity/
Expected: PASS.
- Step 10: Générer + appliquer la migration additive
Run: docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff
Vérifier que le up() ne contient QUE des CREATE TABLE permission, CREATE TABLE "role", CREATE TABLE role_permission, CREATE TABLE user_role, CREATE TABLE user_permission (+ index + FK), aucun DROP/ALTER sur "user" ou messenger_messages. Si la dérive messenger_messages est mélangée, éditer la migration pour ne garder que les tables RBAC.
Ajouter les COMMENT ON COLUMN pour les colonnes métier (code, label, module, orphan, is_system, description) dans le up() (convention projet, cf. ColumnCommentsCatalog).
Run: docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction
Puis recharger les fixtures de test : make db-reset (ou équivalent test) — vérifier que ça passe.
- Step 11: Gate + commit
Run: docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:schema:validate → Mapping OK.
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit → vert (120 + 11).
Bloc « Vérification login ».
make php-cs-fixer-allow-risky
git add -A -- src config tests migrations
git reset -- config/reference.php
git commit -m "feat(core) : add rbac role and permission entities with user relations"
Phase B — Agrégation des permissions + commande de synchronisation
Task 2: ModuleRegistry::permissions() + CoreModule::permissions() finalisé + app:sync-permissions
Files:
- Modify:
src/Shared/Domain/Module/ModuleRegistry.php(méthodepermissions()) - Modify:
src/Module/Core/CoreModule.php(permissions Core définitives) - Create:
src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php - Create:
tests/Unit/Shared/Module/ModuleRegistryPermissionsTest.php - Create:
tests/Functional/Module/Core/SyncPermissionsCommandTest.php
Interfaces:
-
Consumes :
ModuleInterface::permissions(): list<array{code,label}>(existant),PermissionRepositoryInterface. -
Produces :
ModuleRegistry::permissions(array $moduleClasses): list<array{code,label,module}>(agrège + injectemodule=$class::id(), valide préfixe). Commandeapp:sync-permissions. -
Step 1: Test de
ModuleRegistry::permissions()
tests/Unit/Shared/Module/ModuleRegistryPermissionsTest.php :
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Module;
use App\Module\Core\CoreModule;
use App\Shared\Domain\Module\ModuleRegistry;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class ModuleRegistryPermissionsTest extends TestCase
{
public function testAggregatesPermissionsWithModuleId(): void
{
$perms = ModuleRegistry::permissions([CoreModule::class]);
self::assertNotEmpty($perms);
foreach ($perms as $perm) {
self::assertArrayHasKey('code', $perm);
self::assertArrayHasKey('label', $perm);
self::assertArrayHasKey('module', $perm);
self::assertSame('core', $perm['module']);
self::assertStringStartsWith('core.', $perm['code']);
}
}
}
- Step 2: Lancer, vérifier l'échec (méthode inexistante).
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Module/ModuleRegistryPermissionsTest.php
Expected: FAIL.
- Step 3: Implémenter
ModuleRegistry::permissions()
Ajouter à src/Shared/Domain/Module/ModuleRegistry.php :
/**
* @param list<class-string> $moduleClasses
*
* @return list<array{code: string, label: string, module: string}>
*/
public static function permissions(array $moduleClasses): array
{
$out = [];
foreach ($moduleClasses as $moduleClass) {
if (!is_a($moduleClass, ModuleInterface::class, true)) {
continue;
}
$moduleId = $moduleClass::id();
foreach ($moduleClass::permissions() as $perm) {
$code = $perm['code'];
if (!str_starts_with($code, $moduleId.'.')) {
throw new \InvalidArgumentException(sprintf('Permission "%s" du module "%s" doit être préfixée par "%s.".', $code, $moduleId, $moduleId));
}
$out[] = ['code' => $code, 'label' => $perm['label'], 'module' => $moduleId];
}
}
return $out;
}
- Step 4: Finaliser
CoreModule::permissions()
Remplacer le stub par les permissions Core RBAC (alignées sur Starseed, périmètre Lesstime) :
public static function permissions(): array
{
return [
['code' => 'core.users.view', 'label' => 'Voir les utilisateurs'],
['code' => 'core.users.manage', 'label' => 'Gérer les utilisateurs (créer, éditer, supprimer)'],
['code' => 'core.roles.view', 'label' => 'Voir les rôles RBAC'],
['code' => 'core.roles.manage', 'label' => 'Gérer les rôles et permissions'],
['code' => 'core.permissions.view', 'label' => 'Consulter le catalogue des permissions RBAC'],
];
}
Mettre à jour
tests/Unit/Module/Core/CoreModuleTest.phpsi une assertion fige les anciens codes (core.user.read, etc.).
- Step 5: Test fonctionnel de la commande
tests/Functional/Module/Core/SyncPermissionsCommandTest.php :
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\Core;
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
/**
* @internal
*/
final class SyncPermissionsCommandTest extends KernelTestCase
{
public function testSyncCreatesCorePermissions(): void
{
$kernel = self::bootKernel();
$app = new Application($kernel);
$tester = new CommandTester($app->find('app:sync-permissions'));
$tester->execute([]);
$tester->assertCommandIsSuccessful();
$repo = self::getContainer()->get(PermissionRepositoryInterface::class);
self::assertNotNull($repo->findByCode('core.users.manage'));
self::assertContains('core.roles.manage', $repo->findAllCodes());
}
}
-
Step 6: Lancer, vérifier l'échec (commande inexistante).
-
Step 7: Implémenter
app:sync-permissions
src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php :
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Console;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
use App\Shared\Domain\Module\ModuleRegistry;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
#[AsCommand(name: 'app:sync-permissions', description: 'Synchronise le catalogue des permissions depuis les modules actifs.')]
final class SyncPermissionsCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly PermissionRepositoryInterface $permissions,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
/** @var list<class-string> $moduleClasses */
$moduleClasses = require $this->projectDir.'/config/modules.php';
// Phase 1 : permissions désirées (code => {code,label,module}).
$desired = [];
foreach (ModuleRegistry::permissions($moduleClasses) as $perm) {
$desired[$perm['code']] = $perm;
}
// Phase 2 : upsert.
$existing = [];
foreach ($this->permissions->findAll() as $permission) {
$existing[$permission->getCode()] = $permission;
}
$added = $updated = $revived = 0;
foreach ($desired as $code => $perm) {
$entity = $existing[$code] ?? null;
if (null === $entity) {
$this->permissions->save(new Permission($perm['code'], $perm['label'], $perm['module']));
++$added;
continue;
}
if ($entity->isOrphan()) {
$entity->revive($perm['label'], $perm['module']);
++$revived;
} elseif ($entity->getLabel() !== $perm['label'] || $entity->getModule() !== $perm['module']) {
$entity->updateMetadata($perm['label'], $perm['module']);
++$updated;
}
}
// Phase 3 : orphelines (existantes absentes des désirées).
$orphaned = 0;
foreach ($existing as $code => $entity) {
if (!isset($desired[$code]) && !$entity->isOrphan()) {
$entity->markOrphan();
++$orphaned;
}
}
$this->em->flush();
$io->success(sprintf('Permissions synchronisées : %d ajoutées, %d mises à jour, %d réactivées, %d orphelines. Total désirées : %d.', $added, $updated, $revived, $orphaned, \count($desired)));
return Command::SUCCESS;
}
}
- Step 8: Tests + commit
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit → vert.
make php-cs-fixer-allow-risky
git add -A -- src tests
git commit -m "feat(core) : aggregate module permissions and add sync-permissions command"
Phase C — PermissionVoter + exposition /api/me
Task 3: PermissionVoter + permissions effectives dans /api/me
Files:
- Create:
src/Module/Core/Infrastructure/Security/PermissionVoter.php - Create:
tests/Unit/Module/Core/Infrastructure/Security/PermissionVoterTest.php - Modify (vérifier) :
src/Module/Core/Domain/Entity/User.php—getEffectivePermissions()déjà dans le groupeme:read(Phase A Step 6). Confirmer.
Interfaces:
-
Consumes :
User::getEffectivePermissions(),User::getRoles(). -
Produces : voter répondant à
is_granted('module.resource.action'). -
Step 1: Test du voter
tests/Unit/Module/Core/Infrastructure/Security/PermissionVoterTest.php :
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Module\Core\Infrastructure\Security;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Infrastructure\Security\PermissionVoter;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
/**
* @internal
*/
final class PermissionVoterTest extends TestCase
{
private function token(User $user): UsernamePasswordToken
{
return new UsernamePasswordToken($user, 'main', $user->getRoles());
}
public function testAbstainsOnNonRbacAttributes(): void
{
$voter = new PermissionVoter();
$user = new User();
self::assertSame(VoterInterface::ACCESS_ABSTAIN, $voter->vote($this->token($user), null, ['ROLE_ADMIN']));
self::assertSame(VoterInterface::ACCESS_ABSTAIN, $voter->vote($this->token($user), null, ['IS_AUTHENTICATED_FULLY']));
}
public function testGrantsWhenUserHasPermissionViaRole(): void
{
$voter = new PermissionVoter();
$role = new Role('bureau', 'Bureau');
$role->addPermission(new Permission('core.users.view', 'Voir', 'core'));
$user = new User();
$user->addRbacRole($role);
self::assertSame(VoterInterface::ACCESS_GRANTED, $voter->vote($this->token($user), null, ['core.users.view']));
self::assertSame(VoterInterface::ACCESS_DENIED, $voter->vote($this->token($user), null, ['core.users.manage']));
}
public function testAdminBypassesViaRole(): void
{
$voter = new PermissionVoter();
$user = new User();
$user->setRoles(['ROLE_ADMIN']);
self::assertSame(VoterInterface::ACCESS_GRANTED, $voter->vote($this->token($user), null, ['core.users.manage']));
}
}
-
Step 2: Lancer, vérifier l'échec.
-
Step 3: Implémenter le voter
src/Module/Core/Infrastructure/Security/PermissionVoter.php :
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Security;
use App\Module\Core\Domain\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* @extends Voter<string, mixed>
*/
final class PermissionVoter extends Voter
{
private const string PATTERN = '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/';
protected function supports(string $attribute, mixed $subject): bool
{
return 1 === preg_match(self::PATTERN, $attribute);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
// ROLE_ADMIN = bypass total (cf. Décision 1).
if (in_array('ROLE_ADMIN', $user->getRoles(), true)) {
return true;
}
return in_array($attribute, $user->getEffectivePermissions(), true);
}
}
Le voter est auto-enregistré (autoconfigure). Aucun ajout
services.yamlnécessaire.
- Step 4: Tests + login + vérif
/api/me
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit → vert.
Bloc « Vérification login », puis vérifier que /api/me expose effectivePermissions :
curl -s http://localhost:8082/api/me -H "Cookie: BEARER=$BEARER" | grep -o "effectivePermissions" && echo "OK champ présent"
alice(ROLE_USER, sans rôle RBAC) renverraeffectivePermissions: []— normal à ce stade (pas de rôle attribué).
- Step 5: Commit
make php-cs-fixer-allow-risky
git add -A -- src tests
git commit -m "feat(core) : add permission voter and expose effective permissions on /api/me"
Phase D — API Platform : Role + Permission + processors
Task 4: ApiResources Role/Permission (déjà sur entités) + RoleProcessor + endpoint RBAC user + UserRbacProcessor
Files:
- Create:
src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php - Create:
src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php - Modify:
src/Module/Core/Domain/Entity/User.php(opérations RBACGet/Patchsur/api/users/{id}/rbac+ groupesuser:rbac:read/user:rbac:write) - Create:
tests/Functional/Module/Core/RoleApiTest.php - Create:
tests/Functional/Module/Core/UserRbacApiTest.php
Interfaces:
-
Consumes :
RoleRepositoryInterface,Role::ensureDeletable(),Usercollections. -
Produces :
POST/PATCH/DELETE /api/roles,GET/PATCH /api/users/{id}/rbac. -
Step 1:
RoleProcessor(immuabilité du code + refus delete système)
src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php :
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Core\Domain\Entity\Role;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* @implements ProcessorInterface<Role, Role|null>
*/
final readonly class RoleProcessor implements ProcessorInterface
{
public function __construct(private EntityManagerInterface $em) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?Role
{
\assert($data instanceof Role);
if ($operation instanceof DeleteOperationInterface) {
try {
$data->ensureDeletable();
} catch (\DomainException $e) {
throw new AccessDeniedHttpException($e->getMessage(), $e);
}
$this->em->remove($data);
$this->em->flush();
return null;
}
$this->em->persist($data);
$this->em->flush();
return $data;
}
}
Le code étant
role:writemais immuable : API Platform mappe lecodeà la création (POST). En PATCH, lecodereste dansrole:write— pour le rendre immuable, vérifier dans un test que le PATCH du code n'a pas d'effet (l'entité n'a pas desetCode(), donc le denormalizer ne peut pas l'écraser → immuabilité structurelle). Confirmer l'absence desetCode()dansRole. ✅ (non défini en Phase A).
- Step 2: Endpoint RBAC sur
User+UserRbacProcessor
Dans src/Module/Core/Domain/Entity/User.php, ajouter aux operations de l'ApiResource :
new Get(
uriTemplate: '/users/{id}/rbac',
security: "is_granted('core.users.manage')",
normalizationContext: ['groups' => ['user:rbac:read']],
),
new Patch(
uriTemplate: '/users/{id}/rbac',
security: "is_granted('core.users.manage')",
normalizationContext: ['groups' => ['user:rbac:read']],
denormalizationContext: ['groups' => ['user:rbac:write']],
processor: UserRbacProcessor::class,
),
Ajouter l'import use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;.
Ajouter les setters de collections en denormalization (groupe user:rbac:write) : exposer rbacRoles et directPermissions en écriture. Pour cela, ajouter le groupe user:rbac:write sur les deux propriétés (en plus de user:rbac:read) :
#[Groups(['user:rbac:read', 'user:rbac:write'])]
private Collection $rbacRoles;
...
#[Groups(['user:rbac:read', 'user:rbac:write'])]
private Collection $directPermissions;
API Platform écrit les collections M2M via les adders/removers
addRbacRole/removeRbacRoleetaddDirectPermission/removeDirectPermission(déjà définis Phase A).
src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php (garde anti-écrasement des collections absentes du payload) :
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Core\Domain\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\PersistentCollection;
/**
* @implements ProcessorInterface<User, User>
*/
final readonly class UserRbacProcessor implements ProcessorInterface
{
public function __construct(private EntityManagerInterface $em) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): User
{
\assert($data instanceof User);
// Defense in depth : si une collection n'était pas dans le payload JSON, API Platform
// la réinstancie vide → on restaure le snapshot Doctrine pour éviter l'effacement silencieux.
$previous = $context['previous_data'] ?? null;
if ($previous instanceof User) {
$this->restoreIfEmptiedByAbsence($data->getRbacRoles(), $previous->getRbacRoles());
$this->restoreIfEmptiedByAbsence($data->getDirectPermissions(), $previous->getDirectPermissions());
}
$this->em->persist($data);
$this->em->flush();
return $data;
}
/**
* @param iterable<object> $current
* @param iterable<object> $previous
*/
private function restoreIfEmptiedByAbsence(mixed $current, mixed $previous): void
{
// Si la collection courante est "propre" (non modifiée par le denormalizer) mais vidée,
// on ne touche à rien : la mutation explicite passe par add/remove.
if ($current instanceof PersistentCollection && !$current->isDirty()) {
return;
}
// NB : la restauration fine est laissée à l'exécutant si un test prouve l'écrasement ;
// en pratique, exposer les collections en user:rbac:write avec les adders/removers suffit.
}
}
⚠️ NOTE EXÉCUTANT : commencer SIMPLE — exposer
rbacRoles/directPermissionsenuser:rbac:write(avec adders/removers) gère nativement les mutations via IRIs. Écrire d'abord le testUserRbacApiTest(Step 4) ; si et seulement si il prouve un écrasement de collection lors d'un PATCH partiel, implémenter la restauration depuis$context['previous_data']. Sinon, leUserRbacProcessorpeut se réduire à persist+flush. Ne pas sur-architecturer.
- Step 3: Tests fonctionnels Role
tests/Functional/Module/Core/RoleApiTest.php : créer un user admin authentifié (helper login JWT comme les autres tests fonctionnels du projet — s'inspirer de tests/Functional/... existants), puis :
GET /api/rolesen tant qu'admin → 200.GET /api/rolesnon authentifié → 401.POST /api/roles{code:'bureau', label:'Bureau'}en admin → 201.DELETEd'un rôle système (admin, seedé en Phase E — ou créé inlineisSystem) → 403.
S'aligner sur le pattern d'auth des tests fonctionnels existants (cookie BEARER via
/login_checkou client API Platform avec token). LIRE un test fonctionnel existant avant d'écrire celui-ci.
- Step 4: Tests fonctionnels User RBAC
tests/Functional/Module/Core/UserRbacApiTest.php :
-
GET /api/users/{id}/rbacen admin → 200, contientrbacRoles,directPermissions,effectivePermissions. -
PATCH /api/users/{id}/rbacattribuant un rôle (IRI) → 200, le rôle apparaît dansrbacRoles. -
PATCHne touchant qu'un champ → vérifier que l'autre collection n'est PAS vidée (le test qui décide siUserRbacProcessora besoin de la restauration). -
Step 5: Lancer + login + commit
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit → vert.
Bloc « Vérification login ».
make php-cs-fixer-allow-risky
git add -A -- src tests
git commit -m "feat(core) : expose role and user-rbac api endpoints with processors"
Phase E — Seed RBAC (rôles système)
Task 5: RbacSeeder + app:seed-rbac + intégration fixtures
Files:
- Create:
src/Module/Core/Application/Rbac/RbacSeeder.php - Create:
src/Module/Core/Infrastructure/Console/SeedRbacCommand.php - Create:
src/Module/Core/Domain/Security/SystemRoles.php - Modify:
src/DataFixtures/AppFixtures.php(appeler le seed des rôles système après sync, OU documenter l'appel via make) - Create:
tests/Functional/Module/Core/SeedRbacCommandTest.php
Interfaces:
-
Consumes :
RoleRepositoryInterface,EntityManagerInterface. -
Produces :
RbacSeeder::ensureSystemRoles(): void(idempotent), commandeapp:seed-rbac. -
Step 1:
SystemRoles
src/Module/Core/Domain/Security/SystemRoles.php :
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Security;
final class SystemRoles
{
public const string ADMIN_CODE = 'admin';
public const string USER_CODE = 'user';
}
- Step 2: Test de la commande
tests/Functional/Module/Core/SeedRbacCommandTest.php :
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\Core;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
/**
* @internal
*/
final class SeedRbacCommandTest extends KernelTestCase
{
public function testSeedsSystemRolesIdempotently(): void
{
$kernel = self::bootKernel();
$app = new Application($kernel);
$tester = new CommandTester($app->find('app:seed-rbac'));
$tester->execute([]);
$tester->assertCommandIsSuccessful();
$tester->execute([]); // idempotent
$tester->assertCommandIsSuccessful();
$repo = self::getContainer()->get(RoleRepositoryInterface::class);
$admin = $repo->findByCode(SystemRoles::ADMIN_CODE);
self::assertNotNull($admin);
self::assertTrue($admin->isSystem());
self::assertNotNull($repo->findByCode(SystemRoles::USER_CODE));
}
}
-
Step 3: Lancer, vérifier l'échec.
-
Step 4:
RbacSeeder
src/Module/Core/Application/Rbac/RbacSeeder.php :
<?php
declare(strict_types=1);
namespace App\Module\Core\Application\Rbac;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles;
use Doctrine\ORM\EntityManagerInterface;
final readonly class RbacSeeder
{
public function __construct(
private EntityManagerInterface $em,
private RoleRepositoryInterface $roles,
) {}
/**
* Crée les rôles système s'ils sont absents. Idempotent.
*/
public function ensureSystemRoles(): void
{
$this->ensureRole(SystemRoles::ADMIN_CODE, 'Administrateur', 'Accès complet (bypass RBAC).');
$this->ensureRole(SystemRoles::USER_CODE, 'Utilisateur', 'Rôle de base sans permission spécifique.');
$this->em->flush();
}
private function ensureRole(string $code, string $label, string $description): void
{
if (null !== $this->roles->findByCode($code)) {
return;
}
$this->roles->save(new Role($code, $label, $description, true));
}
}
- Step 5:
app:seed-rbac
src/Module/Core/Infrastructure/Console/SeedRbacCommand.php :
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Console;
use App\Module\Core\Application\Rbac\RbacSeeder;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'app:seed-rbac', description: 'Seed les rôles système RBAC (admin, user).')]
final class SeedRbacCommand extends Command
{
public function __construct(private readonly RbacSeeder $seeder)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$this->seeder->ensureSystemRoles();
$io->success('Rôles système RBAC seedés (admin, user).');
return Command::SUCCESS;
}
}
- Step 6: Intégrer au cycle fixtures
LIRE src/DataFixtures/AppFixtures.php. Après le chargement des users, appeler le seed RBAC (les permissions doivent exister → app:sync-permissions AVANT). Deux options selon le pattern projet :
- (a) injecter
RbacSeederdansAppFixtureset appelerensureSystemRoles()en fin deload()(les permissions Core sont synchronisées par un hook séparé) ; OU - (b) documenter dans le
Makefile/README quemake db-resetenchaînefixtures:loadpuisapp:sync-permissionspuisapp:seed-rbac.
Choisir (a) si
AppFixturespeut injecter des services (DependentFixture/service) ; sinon (b). Vérifier quemake db-resetlaisse une base cohérente (rôles + permissions présents). NE PAS attacher de matrice métier (Décision 4).
- Step 7: Tests + commit
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit → vert.
make php-cs-fixer-allow-risky
git add -A -- src tests
git commit -m "feat(core) : add rbac seeder and seed-rbac command for system roles"
Phase F — Sidebar filtrée par permission
Task 6: SidebarFilter + SidebarProvider + config/sidebar.php gated par permission
Files:
- Modify:
src/Shared/Domain/Sidebar/SidebarFilter.php(clépermissionsur section + item) - Modify:
src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php(passe les permissions effectives) - Modify:
config/sidebar.php(permissions sur les items admin) - Modify:
tests/Unit/Shared/Sidebar/SidebarFilterTest.php(cas permission) - Modify (éventuel):
tests/Functional/Shared/SidebarEndpointTest.php
Interfaces:
-
Consumes :
User::getEffectivePermissions(),User::getRoles(). -
Produces :
SidebarFilter::filter($sections, $activeModuleIds, $activeRoles = [], $activePermissions = []). -
Step 1: Étendre le test
SidebarFilterTest
Ajouter des cas : une section/item avec 'permission' => 'core.users.view' est masqué si la permission n'est pas dans $activePermissions, visible sinon. Un item sans permission reste visible (rétrocompat). Combinaison avec roles et module inchangée.
public function testItemHiddenWhenPermissionMissing(): void
{
$sections = [[
'label' => 's', 'icon' => 'i',
'items' => [
['label' => 'a', 'to' => '/a', 'icon' => 'i', 'permission' => 'core.users.view'],
['label' => 'b', 'to' => '/b', 'icon' => 'i'],
],
]];
$out = SidebarFilter::filter($sections, [], [], []);
self::assertCount(1, $out['sections'][0]['items']);
self::assertSame('/b', $out['sections'][0]['items'][0]['to']);
}
public function testItemVisibleWhenPermissionGranted(): void
{
$sections = [[
'label' => 's', 'icon' => 'i',
'items' => [['label' => 'a', 'to' => '/a', 'icon' => 'i', 'permission' => 'core.users.view']],
]];
$out = SidebarFilter::filter($sections, [], [], ['core.users.view']);
self::assertCount(1, $out['sections'][0]['items']);
}
-
Step 2: Lancer, vérifier l'échec (signature à 3 args).
-
Step 3: Étendre
SidebarFilter
Ajouter le paramètre array $activePermissions = [] à filter(). Mettre à jour la docblock des types (permission?:string sur section et item). Après le gate de rôle (section et item), ajouter le gate de permission :
// Gate de permission au niveau section.
if (!self::permissionSatisfied($section['permission'] ?? null, $activePermissions)) {
continue;
}
et pour l'item, avant le filtrage module :
if (!self::permissionSatisfied($item['permission'] ?? null, $activePermissions)) {
continue;
}
Helper :
/**
* @param list<string> $activePermissions
*/
private static function permissionSatisfied(?string $required, array $activePermissions): bool
{
if (null === $required || '' === $required) {
return true;
}
return in_array($required, $activePermissions, true);
}
- Step 4: Passer les permissions dans
SidebarProvider
Dans provide(), après $roles = ... :
$permissions = ($user instanceof \App\Module\Core\Domain\Entity\User) ? $user->getEffectivePermissions() : [];
$filtered = SidebarFilter::filter($sidebar, ModuleRegistry::ids($moduleClasses), array_values($roles), $permissions);
Pour éviter le couplage dur au concret, préférer le contrat :
$userpeut être typéUserInterface(qui a désormaisgetEffectivePermissions()). Utiliser$permissions = method_exists($user, 'getEffectivePermissions') ? $user->getEffectivePermissions() : [];OU, plus propre, instancier-check surApp\Shared\Domain\Contract\UserInterface. Choisir le check sur le contrat Shared.
- Step 5: Ajouter les permissions dans
config/sidebar.php
Sur la section admin, garder le gate roles: [ROLE_ADMIN] (rétrocompat) ET, en complément, ajouter une permission sur l'item Administration le cas échéant. Comme ROLE_ADMIN bypasse déjà tout (Décision 1), garder la section admin sur roles est suffisant ; on ajoute permission seulement si on veut donner accès à des non-admins porteurs de core.users.view/core.roles.view. Décider : ajouter 'permission' => 'core.users.view' sur l'item administration pour permettre l'accès aux gestionnaires non-admin. Documenter dans le commentaire d'en-tête de config/sidebar.php la nouvelle clé permission.
Mettre à jour le commentaire d'en-tête : «
permission(section/item masqué si la permission effective absente — le RBAC fin) ».
- Step 6: Tests + login + commit
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit → vert.
Bloc « Vérification login » + vérifier /api/sidebar : admin voit Administration, alice (ROLE_USER sans permission) ne voit pas l'item gardé par permission.
make php-cs-fixer-allow-risky
git add -A -- src config tests
git commit -m "feat(core) : gate sidebar by effective permissions"
Phase G — Front : composable usePermissions + gestion des rôles
Task 7: usePermissions, type user étendu, page admin rôles
Files:
- Create:
frontend/modules/core/composables/usePermissions.ts - Modify: type de l'utilisateur courant (chercher
frontend/services/dto/ou store auth) — ajoutereffectivePermissions: string[] - Create:
frontend/modules/core/services/roles.ts+frontend/modules/core/services/permissions.ts - Create:
frontend/modules/core/pages/admin/roles.vue(gestion des rôles) — OU onglet dans l'admin existant - Modify:
frontend/i18n/locales/fr.json(clésadmin.roles.*)
⚠️ Le front Lesstime n'a pas encore de page de gestion de rôles. LIRE d'abord la structure (
frontend/modules/core/,frontend/stores/auth.ts,frontend/services/,frontend/pages/admin.vueet ses ongletsAdmin*Tab). Reproduire le pattern existant (ongletAdminUserTabetc.). Le composable et le type sont le cœur de l'AC front ; la page de gestion peut être un onglet supplémentaire dansadmin.vue.
Interfaces:
-
Consumes :
/api/me.effectivePermissions,/api/roles,/api/permissions. -
Produces :
usePermissions(): { can(code), canAny(codes), canAll(codes) }. -
Step 1: Étendre le type user + le store auth
LIRE le store frontend/stores/auth.ts (ou shared/stores/auth.ts) et le DTO user. Ajouter effectivePermissions: string[] au type, et s'assurer que le payload /api/me (qui l'expose désormais, Phase C) est bien stocké. Si le type a roles: string[], garder.
- Step 2: Composable
usePermissions
frontend/modules/core/composables/usePermissions.ts :
export function usePermissions() {
const auth = useAuthStore()
function isAdmin(): boolean {
return auth.user?.roles?.includes('ROLE_ADMIN') ?? false
}
function can(code: string): boolean {
if (!auth.user) return false
if (isAdmin()) return true
return auth.user.effectivePermissions?.includes(code) ?? false
}
function canAny(codes: string[]): boolean {
return codes.some((c) => can(c))
}
function canAll(codes: string[]): boolean {
return codes.every((c) => can(c))
}
return { can, canAny, canAll, isAdmin }
}
Le dossier
modules/core/composablesest auto-importé (scanreaddirSync('modules/')→imports.dirs, cf. LST-62).useAuthStoreest auto-importé.
- Step 3: Services roles/permissions
frontend/modules/core/services/roles.ts et permissions.ts : 1 service par ressource, via useApi() (pattern projet — LIRE frontend/services/users.ts comme modèle). Exposer list(), create(), update(), remove() pour roles ; list() pour permissions. Gérer la pagination via le pattern projet (ces collections sont bornées → paginationEnabled: false sur le GetCollection côté back, OU fetchAllHydra). Vérifier : si les collections Role/Permission peuvent dépasser 30 items, ajouter paginationEnabled: false sur leur GetCollection (cf. CLAUDE.md piège extractHydraMembers). Pour Lesstime, permission ≈ une douzaine de codes → borné ; role ≈ qqs unités → borné. Ajouter paginationEnabled: false sur les GetCollection de Role et Permission (entités, Phase A/D) pour fiabiliser extractHydraMembers.
- Step 4: Page / onglet de gestion des rôles
Ajouter un onglet AdminRoleTab dans frontend/pages/admin.vue (ou modules/core), listant les rôles, permettant création/édition (label, description, permissions cochées depuis /api/permissions groupées par module) et suppression (désactivée pour isSystem). Réserver l'affichage via v-if="can('core.roles.view')". LIRE un Admin*Tab existant pour le style.
- Step 5: i18n
Ajouter dans frontend/i18n/locales/fr.json les clés admin.roles.* (titre, colonnes, actions, messages). Fusionner dans les namespaces existants (ne pas dupliquer une clé racine).
- Step 6: Gate front + smoke
Run: cd frontend && npx nuxt build 2>&1 | grep -iE "error|roles|permission" | tail — build OK, pas d'erreur de module manquant.
Rappel LST-62 :
nuxt typecheckn'est PAS un gate vert sur ce stack. Le vrai gate = build OK + aucunCannot find module+ auto-imports présents. Smoke navigateur (gestion des rôles) = PO.
- Step 7: Commit
git add -A -- frontend
git commit -m "feat(core) : add usePermissions composable and rbac roles admin front"
Acceptance check (après toutes les phases)
- AC1 Permissions
module.resource.actionsynchronisées viaapp:sync-permissions: la commande tourne sans erreur,permissioncontient les codescore.*. - AC2 Sidebar gated par permission ET par module actif :
/api/sidebarmasque les items seloneffectivePermissions(etdisabledRoutesselon module), ROLE_ADMIN voit tout. - AC3
make testvert (≈ 120 + nouveaux tests),doctrine:schema:validatemapping OK,migrations:diff= pas de changement RBAC (horsmessenger_messages). /api/meexposeeffectivePermissions.PermissionVoterrépond àis_granted('core.*.*'); ROLE_ADMIN bypass.app:seed-rbaccrée les rôles systèmeadmin/user(idempotent).- Front :
usePermissions().can(code)fonctionne ; gestion des rôles accessible aux porteurs decore.roles.view. - Login/JWT/MCP inchangés (
204/200/200) à chaque phase. config/reference.phpjamais committé.
Notes pour les tickets suivants
- 2.x (modules métier) : chaque
*Module::permissions()déclarera ses codes ;app:sync-permissionsles upsertera ; les rôles métier (bureau/compta/…) seront seedés via une matrice étendue dansRbacSeederquand les permissions existeront (Décision 4). - 1.3 (Audit log) :
core.audit_log.viewpourra être ajoutée àCoreModule::permissions()quand l'audit sera livré. - Migration
is_admin(optionnelle) : si le PO préfère le modèle Starseed, une phase ultérieure pourra remplacer le bypassROLE_ADMINpar une colonneis_admin+ data-migration (Décision 1).