feat(core) : RBAC Task 3 - mutation User (isAdmin + roles RBAC + permissions directes)

- Suppression de la colonne JSON roles (persiste jusqu'a la migration Task 5)
- Ajout is_admin bool (seul levier de bypass RBAC via getRoles())
- Ajout ManyToMany User-Role (EAGER, table user_role)
- Ajout ManyToMany User-Permission directes (EAGER, table user_permission)
- getEffectivePermissions() : union dedupliquee triee, utilisee par le
  futur PermissionVoter (#345)
- getRbacRoles() pour ne pas shadow getRoles() de UserInterface Symfony
- Tests unitaires couvrant derivation getRoles, union, deduplication, tri

Ticket #343 - 3/7 : migration du User vers le modele RBAC relationnel.
Fetch EAGER documente : evite le lazy-load au refresh JWT.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-04-14 16:48:49 +02:00
parent 3b34d00872
commit 7aa32b1972
4 changed files with 282 additions and 15 deletions

View File

@@ -14,6 +14,8 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserPasswordHashe
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
@@ -52,10 +54,37 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?string $username = null;
/** @var list<string> */
#[ORM\Column]
#[ORM\Column(name: 'is_admin', options: ['default' => false])]
#[Groups(['me:read', 'user:list', 'user:write'])]
private array $roles = [];
private bool $isAdmin = false;
/**
* Les roles RBAC metier rattaches a l'utilisateur.
*
* Le fetch EAGER est delibere : evite un lazy-load silencieux pendant
* un refresh de token JWT ou une serialisation hors contexte EntityManager
* (cf. docs/rbac/ticket-343-spec.md section 11 risque 1). Le surcout SQL est
* accepte a l'echelle d'un CRM/ERP PME ; a revoir si la volumetrie augmente.
*
* @var Collection<int, Role>
*/
#[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_role')]
#[Groups(['me:read', 'user:list', 'user:write'])]
private Collection $roles;
/**
* Les permissions directes accordees hors des roles.
*
* Meme justification EAGER que pour $roles : garantie que
* getEffectivePermissions() fonctionne dans tous les contextes de chargement.
*
* @var Collection<int, Permission>
*/
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_permission')]
#[Groups(['me:read', 'user:list', 'user:write'])]
private Collection $directPermissions;
#[ORM\Column]
private ?string $password = null;
@@ -68,7 +97,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->createdAt = new DateTimeImmutable();
$this->roles = new ArrayCollection();
$this->directPermissions = new ArrayCollection();
}
public function getId(): ?int
@@ -93,23 +124,127 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return (string) $this->username;
}
/** @return list<string> */
/**
* Retourne les roles Symfony Security, derives de $isAdmin.
*
* ROLE_USER est toujours present pour que Symfony accepte l'authentification.
* ROLE_ADMIN est ajoute si l'utilisateur porte le flag is_admin — c'est le
* SEUL levier technique de bypass RBAC (cf. section 11 du spec).
*
* Important : ne JAMAIS iterer $this->roles (la Collection de Role) ici.
* Cette methode peut etre appelee pendant un refresh JWT, moment ou la
* Collection peut ne pas etre hydratee. On se contente d'un calcul base
* sur un scalaire.
*
* @return list<string>
*/
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
$roles = ['ROLE_USER'];
return array_values(array_unique($roles));
if ($this->isAdmin) {
$roles[] = 'ROLE_ADMIN';
}
return $roles;
}
/** @param list<string> $roles */
public function setRoles(array $roles): static
public function isAdmin(): bool
{
$this->roles = $roles;
return $this->isAdmin;
}
public function setIsAdmin(bool $isAdmin): static
{
$this->isAdmin = $isAdmin;
return $this;
}
/**
* Retourne la collection de roles RBAC rattaches a l'utilisateur.
*
* NE PAS confondre avec getRoles() qui renvoie les roles Symfony scalaires.
*
* @return Collection<int, Role>
*/
public function getRbacRoles(): Collection
{
return $this->roles;
}
public function addRbacRole(Role $role): static
{
if (!$this->roles->contains($role)) {
$this->roles->add($role);
}
return $this;
}
public function removeRbacRole(Role $role): static
{
$this->roles->removeElement($role);
return $this;
}
/**
* @return Collection<int, Permission>
*/
public function getDirectPermissions(): Collection
{
return $this->directPermissions;
}
public function addDirectPermission(Permission $permission): static
{
if (!$this->directPermissions->contains($permission)) {
$this->directPermissions->add($permission);
}
return $this;
}
public function removeDirectPermission(Permission $permission): static
{
$this->directPermissions->removeElement($permission);
return $this;
}
/**
* Retourne l'union dedupliquee des codes de permissions effectives.
*
* Agrege les permissions venant des roles RBAC et les permissions directes.
* Utilisee par le PermissionVoter (ticket #345) et exposee via /api/me
* apres l'evolution du MeProvider (aussi ticket #345).
*
* Ne PAS appeler dans getRoles() : voir commentaire sur cette derniere
* methode pour le piege de chargement au refresh JWT.
*
* @return list<string>
*/
public function getEffectivePermissions(): array
{
$codes = [];
foreach ($this->roles 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;
}
public function getPassword(): ?string
{
return $this->password;

View File

@@ -49,7 +49,8 @@ class CreateUserCommand extends Command
$user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword));
if ($input->getOption('admin')) {
$user->setRoles(['ROLE_ADMIN']);
// TODO Task 6 : attacher l'entite Role "admin" en plus du flag is_admin.
$user->setIsAdmin(true);
}
$this->userRepository->save($user);

View File

@@ -17,21 +17,20 @@ class AppFixtures extends Fixture
public function load(ObjectManager $manager): void
{
// TODO Task 6 : cette fixture sera refactoree pour attacher les entites Role RBAC.
$admin = new User();
$admin->setUsername('admin');
$admin->setRoles(['ROLE_ADMIN']);
$admin->setIsAdmin(true);
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
$manager->persist($admin);
$alice = new User();
$alice->setUsername('alice');
$alice->setRoles(['ROLE_USER']);
$alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice'));
$manager->persist($alice);
$bob = new User();
$bob->setUsername('bob');
$bob->setRoles(['ROLE_USER']);
$bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob'));
$manager->persist($bob);

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Domain\Entity;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class UserTest extends TestCase
{
public function testGetRolesReturnsRoleUserByDefault(): void
{
$user = new User();
self::assertSame(['ROLE_USER'], $user->getRoles());
}
public function testGetRolesIncludesRoleAdminWhenIsAdminTrue(): void
{
$user = new User();
$user->setIsAdmin(true);
self::assertSame(['ROLE_USER', 'ROLE_ADMIN'], $user->getRoles());
}
public function testIsAdminDefaultsToFalse(): void
{
$user = new User();
self::assertFalse($user->isAdmin());
}
public function testGetEffectivePermissionsIsEmptyByDefault(): void
{
$user = new User();
self::assertSame([], $user->getEffectivePermissions());
}
public function testGetEffectivePermissionsUnionsRolesAndDirects(): void
{
$perm1 = new Permission('core.users.view', 'View users', 'core');
$perm2 = new Permission('core.users.edit', 'Edit users', 'core');
$perm3 = new Permission('core.users.delete', 'Delete users', 'core');
$role = new Role('manager', 'Manager');
$role->addPermission($perm1);
$role->addPermission($perm2);
$user = new User();
$user->addRbacRole($role);
$user->addDirectPermission($perm3);
self::assertSame(
['core.users.delete', 'core.users.edit', 'core.users.view'],
$user->getEffectivePermissions(),
);
}
public function testGetEffectivePermissionsDeduplicatesAcrossRolesAndDirects(): void
{
$perm = new Permission('core.users.view', 'View users', 'core');
$role = new Role('viewer', 'Viewer');
$role->addPermission($perm);
$user = new User();
$user->addRbacRole($role);
$user->addDirectPermission($perm);
$result = $user->getEffectivePermissions();
self::assertCount(1, $result);
self::assertSame(['core.users.view'], $result);
}
public function testAddRbacRoleIsIdempotent(): void
{
$role = new Role('manager', 'Manager');
$user = new User();
$user->addRbacRole($role);
$user->addRbacRole($role);
self::assertSame(1, $user->getRbacRoles()->count());
}
public function testAddDirectPermissionIsIdempotent(): void
{
$perm = new Permission('core.users.view', 'View users', 'core');
$user = new User();
$user->addDirectPermission($perm);
$user->addDirectPermission($perm);
self::assertSame(1, $user->getDirectPermissions()->count());
}
public function testRemoveRbacRole(): void
{
$role = new Role('manager', 'Manager');
$user = new User();
$user->addRbacRole($role);
$user->removeRbacRole($role);
self::assertSame(0, $user->getRbacRoles()->count());
}
public function testGetEffectivePermissionsOutputIsSorted(): void
{
$permZ = new Permission('core.z.action', 'Z', 'core');
$permA = new Permission('core.a.action', 'A', 'core');
$permM = new Permission('core.m.action', 'M', 'core');
$user = new User();
$user->addDirectPermission($permZ);
$user->addDirectPermission($permA);
$user->addDirectPermission($permM);
self::assertSame(
['core.a.action', 'core.m.action', 'core.z.action'],
$user->getEffectivePermissions(),
);
}
}