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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
132
tests/Module/Core/Domain/Entity/UserTest.php
Normal file
132
tests/Module/Core/Domain/Entity/UserTest.php
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user