From 7aa32b1972d71ea979b3be1ba7f1e61d4dd9a419 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 14 Apr 2026 16:48:49 +0200 Subject: [PATCH] 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) --- src/Module/Core/Domain/Entity/User.php | 157 ++++++++++++++++-- .../Console/CreateUserCommand.php | 3 +- .../DataFixtures/AppFixtures.php | 5 +- tests/Module/Core/Domain/Entity/UserTest.php | 132 +++++++++++++++ 4 files changed, 282 insertions(+), 15 deletions(-) create mode 100644 tests/Module/Core/Domain/Entity/UserTest.php diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index cef1bd0..d06efdd 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -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 */ - #[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 + */ + #[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 + */ + #[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 */ + /** + * 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 + */ 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 $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 + */ + 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 + */ + 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 + */ + 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; diff --git a/src/Module/Core/Infrastructure/Console/CreateUserCommand.php b/src/Module/Core/Infrastructure/Console/CreateUserCommand.php index ba647e2..7133adc 100644 --- a/src/Module/Core/Infrastructure/Console/CreateUserCommand.php +++ b/src/Module/Core/Infrastructure/Console/CreateUserCommand.php @@ -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); diff --git a/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php b/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php index f58a6bf..0ba45fa 100644 --- a/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php +++ b/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php @@ -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); diff --git a/tests/Module/Core/Domain/Entity/UserTest.php b/tests/Module/Core/Domain/Entity/UserTest.php new file mode 100644 index 0000000..5c3b362 --- /dev/null +++ b/tests/Module/Core/Domain/Entity/UserTest.php @@ -0,0 +1,132 @@ +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(), + ); + } +}