RBAC - Système complet de permissions (Backend + Frontend) #7
@@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection;
|
|||||||
use ApiPlatform\Metadata\Patch;
|
use ApiPlatform\Metadata\Patch;
|
||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserPasswordHasherProcessor;
|
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserPasswordHasherProcessor;
|
||||||
|
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
|
||||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
|
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
|
||||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
@@ -20,6 +21,7 @@ use Doctrine\ORM\Mapping as ORM;
|
|||||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
@@ -36,6 +38,15 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
),
|
),
|
||||||
new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
||||||
new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
||||||
|
new Patch(
|
||||||
|
name: 'user_rbac_patch',
|
||||||
|
uriTemplate: '/users/{id}/rbac',
|
||||||
|
// TODO ticket #345 : remplacer par is_granted('core.users.manage')
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
normalizationContext: ['groups' => ['user:list']],
|
||||||
|
denormalizationContext: ['groups' => ['user:rbac:write']],
|
||||||
|
processor: UserRbacProcessor::class,
|
||||||
|
),
|
||||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||||
],
|
],
|
||||||
denormalizationContext: ['groups' => ['user:write']],
|
denormalizationContext: ['groups' => ['user:write']],
|
||||||
@@ -55,7 +66,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
private ?string $username = null;
|
private ?string $username = null;
|
||||||
|
|
||||||
#[ORM\Column(name: 'is_admin', options: ['default' => false])]
|
#[ORM\Column(name: 'is_admin', options: ['default' => false])]
|
||||||
#[Groups(['me:read', 'user:list'])]
|
#[Groups(['me:read', 'user:list', 'user:rbac:write'])]
|
||||||
private bool $isAdmin = false;
|
private bool $isAdmin = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,20 +81,25 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
*/
|
*/
|
||||||
#[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')]
|
#[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')]
|
||||||
#[ORM\JoinTable(name: 'user_role')]
|
#[ORM\JoinTable(name: 'user_role')]
|
||||||
#[Groups(['me:read', 'user:list'])]
|
#[Groups(['me:read', 'user:list', 'user:rbac:write'])]
|
||||||
private Collection $roles;
|
// La propriete s'appelle `rbacRoles` cote PHP pour ne pas entrer en
|
||||||
|
// collision avec UserInterface::getRoles() (qui renvoie list<string>) ;
|
||||||
|
// on reexpose la cle JSON sous `roles` via SerializedName pour rester
|
||||||
|
// conforme au contrat API documente dans le ticket #344.
|
||||||
|
#[SerializedName('roles')]
|
||||||
|
private Collection $rbacRoles;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Les permissions directes accordees hors des roles.
|
* Les permissions directes accordees hors des roles.
|
||||||
*
|
*
|
||||||
* Meme justification EAGER que pour $roles : garantie que
|
* Meme justification EAGER que pour $rbacRoles : garantie que
|
||||||
* getEffectivePermissions() fonctionne dans tous les contextes de chargement.
|
* getEffectivePermissions() fonctionne dans tous les contextes de chargement.
|
||||||
*
|
*
|
||||||
* @var Collection<int, Permission>
|
* @var Collection<int, Permission>
|
||||||
*/
|
*/
|
||||||
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
|
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
|
||||||
#[ORM\JoinTable(name: 'user_permission')]
|
#[ORM\JoinTable(name: 'user_permission')]
|
||||||
#[Groups(['me:read', 'user:list'])]
|
#[Groups(['me:read', 'user:list', 'user:rbac:write'])]
|
||||||
private Collection $directPermissions;
|
private Collection $directPermissions;
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
@@ -98,7 +114,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->createdAt = new DateTimeImmutable();
|
$this->createdAt = new DateTimeImmutable();
|
||||||
$this->roles = new ArrayCollection();
|
$this->rbacRoles = new ArrayCollection();
|
||||||
$this->directPermissions = new ArrayCollection();
|
$this->directPermissions = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,10 +147,10 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
* ROLE_ADMIN est ajoute si l'utilisateur porte le flag is_admin — c'est le
|
* 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).
|
* SEUL levier technique de bypass RBAC (cf. section 11 du spec).
|
||||||
*
|
*
|
||||||
* Important : ne JAMAIS iterer $this->roles (la Collection de Role) ici.
|
* Important : ne JAMAIS iterer $this->rbacRoles (la Collection de Role)
|
||||||
* Cette methode peut etre appelee pendant un refresh JWT, moment ou la
|
* ici. Cette methode peut etre appelee pendant un refresh JWT, moment ou
|
||||||
* Collection peut ne pas etre hydratee. On se contente d'un calcul base
|
* la Collection peut ne pas etre hydratee. On se contente d'un calcul
|
||||||
* sur un scalaire.
|
* base sur un scalaire.
|
||||||
*
|
*
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
@@ -170,13 +186,13 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
*/
|
*/
|
||||||
public function getRbacRoles(): Collection
|
public function getRbacRoles(): Collection
|
||||||
{
|
{
|
||||||
return $this->roles;
|
return $this->rbacRoles;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addRbacRole(Role $role): static
|
public function addRbacRole(Role $role): static
|
||||||
{
|
{
|
||||||
if (!$this->roles->contains($role)) {
|
if (!$this->rbacRoles->contains($role)) {
|
||||||
$this->roles->add($role);
|
$this->rbacRoles->add($role);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
@@ -184,7 +200,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
|
|
||||||
public function removeRbacRole(Role $role): static
|
public function removeRbacRole(Role $role): static
|
||||||
{
|
{
|
||||||
$this->roles->removeElement($role);
|
$this->rbacRoles->removeElement($role);
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
@@ -229,7 +245,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
{
|
{
|
||||||
$codes = [];
|
$codes = [];
|
||||||
|
|
||||||
foreach ($this->roles as $role) {
|
foreach ($this->rbacRoles as $role) {
|
||||||
foreach ($role->getPermissions() as $permission) {
|
foreach ($role->getPermissions() as $permission) {
|
||||||
$codes[$permission->getCode()] = true;
|
$codes[$permission->getCode()] = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?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 Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processor dedie a l'endpoint RBAC `PATCH /api/users/{id}/rbac`.
|
||||||
|
*
|
||||||
|
* Delegue la persistance au PersistProcessor Doctrine decore apres avoir
|
||||||
|
* applique les gardes metier propres aux changements de droits. Cet endpoint
|
||||||
|
* ne touche JAMAIS au mot de passe — c'est une separation volontaire avec le
|
||||||
|
* UserPasswordHasherProcessor qui gere le endpoint profil `/api/users/{id}`.
|
||||||
|
*
|
||||||
|
* Gardes metier :
|
||||||
|
* - Auto-suicide : un admin ne peut pas retirer son propre flag `isAdmin`.
|
||||||
|
* On compare l'etat entrant a l'etat d'origine via l'UnitOfWork Doctrine,
|
||||||
|
* en restreignant la verification au couple "user courant == user cible".
|
||||||
|
*
|
||||||
|
* TODO ticket #345 : garde "dernier admin" globale via inventaire des admins
|
||||||
|
* restants (empeche de retirer `isAdmin` au dernier admin de l'instance, meme
|
||||||
|
* si ce n'est pas sa propre operation).
|
||||||
|
*
|
||||||
|
* @implements ProcessorInterface<User, User>
|
||||||
|
*/
|
||||||
|
final class UserRbacProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||||
|
private readonly ProcessorInterface $persistProcessor,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||||
|
{
|
||||||
|
if (!$data instanceof User) {
|
||||||
|
// Securite : si le provider n'a pas fourni un User, on delegue
|
||||||
|
// quand meme pour ne pas etouffer un bug de configuration.
|
||||||
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentUser = $this->security->getUser();
|
||||||
|
|
||||||
|
// Garde auto-suicide : l'user courant ne peut pas retirer son propre
|
||||||
|
// flag admin. On ne compare que si la cible == l'user courant.
|
||||||
|
if ($currentUser instanceof User
|
||||||
|
&& null !== $currentUser->getId()
|
||||||
|
&& $currentUser->getId() === $data->getId()
|
||||||
|
) {
|
||||||
|
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data);
|
||||||
|
$wasAdmin = $originalData['isAdmin'] ?? null;
|
||||||
|
|
||||||
|
if (true === $wasAdmin && false === $data->isAdmin()) {
|
||||||
|
throw new BadRequestHttpException(
|
||||||
|
'Vous ne pouvez pas retirer vos propres droits administrateur.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
}
|
||||||
|
}
|
||||||
271
tests/Module/Core/Api/UserRbacApiTest.php
Normal file
271
tests/Module/Core/Api/UserRbacApiTest.php
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Core\Api;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\Permission;
|
||||||
|
use App\Module\Core\Domain\Entity\Role;
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests fonctionnels de l'endpoint RBAC dedie `PATCH /api/users/{id}/rbac`.
|
||||||
|
*
|
||||||
|
* Strategie de donnees :
|
||||||
|
* - On cree des users, roles et permissions prefixes `test_` / `test.`
|
||||||
|
* en setUp et on les purge en tearDown.
|
||||||
|
* - On ne touche JAMAIS aux fixtures (admin / alice / bob). Les cas qui
|
||||||
|
* ont besoin d'un user standard authentifie s'appuient sur alice sans
|
||||||
|
* modification d'etat.
|
||||||
|
* - Les users de test incluent un admin dedie pour le cas d'auto-suicide,
|
||||||
|
* pour ne pas risquer de corrompre l'admin fixture.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class UserRbacApiTest extends AbstractApiTestCase
|
||||||
|
{
|
||||||
|
private const TEST_USER_PREFIX = 'test_';
|
||||||
|
private const TEST_ROLE_PREFIX = 'test_';
|
||||||
|
private const TEST_PERMISSION_PREFIX = 'test.';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
self::bootKernel();
|
||||||
|
$em = $this->getEm();
|
||||||
|
|
||||||
|
$this->cleanupTestData();
|
||||||
|
|
||||||
|
/** @var UserPasswordHasherInterface $hasher */
|
||||||
|
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||||
|
|
||||||
|
// User cible standard (non admin).
|
||||||
|
$target = new User();
|
||||||
|
$target->setUsername('test_target');
|
||||||
|
$target->setIsAdmin(false);
|
||||||
|
$target->setPassword($hasher->hashPassword($target, 'secret'));
|
||||||
|
$em->persist($target);
|
||||||
|
|
||||||
|
// User admin dedie pour le cas d'auto-suicide (pas l'admin fixture).
|
||||||
|
$selfAdmin = new User();
|
||||||
|
$selfAdmin->setUsername('test_self_admin');
|
||||||
|
$selfAdmin->setIsAdmin(true);
|
||||||
|
$selfAdmin->setPassword($hasher->hashPassword($selfAdmin, 'secret'));
|
||||||
|
$em->persist($selfAdmin);
|
||||||
|
|
||||||
|
// Role custom pour tester le remplacement de la collection roles.
|
||||||
|
$role = new Role('test_editor', 'Editeur (test)', false);
|
||||||
|
$em->persist($role);
|
||||||
|
|
||||||
|
// Permission custom pour tester directPermissions.
|
||||||
|
$permission = new Permission('test.core.users.view', 'View users (test)', 'core');
|
||||||
|
$em->persist($permission);
|
||||||
|
|
||||||
|
$em->flush();
|
||||||
|
$em->clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->cleanupTestData();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchRbacPromotesUserToAdmin(): void
|
||||||
|
{
|
||||||
|
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
||||||
|
self::assertNotNull($target);
|
||||||
|
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
|
||||||
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||||
|
'json' => ['isAdmin' => true],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$em = $this->getEm();
|
||||||
|
$em->clear();
|
||||||
|
|
||||||
|
/** @var User $reloaded */
|
||||||
|
$reloaded = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
||||||
|
self::assertTrue($reloaded->isAdmin());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchRbacReplacesRolesCollection(): void
|
||||||
|
{
|
||||||
|
$em = $this->getEm();
|
||||||
|
$target = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
||||||
|
$role = $em->getRepository(Role::class)->findOneBy(['code' => 'test_editor']);
|
||||||
|
self::assertNotNull($target);
|
||||||
|
self::assertNotNull($role);
|
||||||
|
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
|
||||||
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||||
|
'json' => ['roles' => ['/api/roles/'.$role->getId()]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$em = $this->getEm();
|
||||||
|
$em->clear();
|
||||||
|
|
||||||
|
/** @var User $reloaded */
|
||||||
|
$reloaded = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
||||||
|
self::assertCount(1, $reloaded->getRbacRoles());
|
||||||
|
self::assertSame('test_editor', $reloaded->getRbacRoles()->first()->getCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchRbacReplacesDirectPermissionsCollection(): void
|
||||||
|
{
|
||||||
|
$em = $this->getEm();
|
||||||
|
$target = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
||||||
|
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'test.core.users.view']);
|
||||||
|
self::assertNotNull($target);
|
||||||
|
self::assertNotNull($permission);
|
||||||
|
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
|
||||||
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||||
|
'json' => ['directPermissions' => ['/api/permissions/'.$permission->getId()]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$em = $this->getEm();
|
||||||
|
$em->clear();
|
||||||
|
|
||||||
|
/** @var User $reloaded */
|
||||||
|
$reloaded = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
||||||
|
self::assertCount(1, $reloaded->getDirectPermissions());
|
||||||
|
self::assertSame('test.core.users.view', $reloaded->getDirectPermissions()->first()->getCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchRbacAsStandardUserReturns403(): void
|
||||||
|
{
|
||||||
|
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
||||||
|
self::assertNotNull($target);
|
||||||
|
|
||||||
|
$client = $this->authenticatedClient('alice', 'alice');
|
||||||
|
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
|
||||||
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||||
|
'json' => ['isAdmin' => true],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchRbacUnauthenticatedReturns401(): void
|
||||||
|
{
|
||||||
|
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
||||||
|
self::assertNotNull($target);
|
||||||
|
|
||||||
|
$client = self::createClient();
|
||||||
|
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
|
||||||
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||||
|
'json' => ['isAdmin' => true],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchRbacIgnoresUsernameField(): void
|
||||||
|
{
|
||||||
|
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
||||||
|
self::assertNotNull($target);
|
||||||
|
$targetId = $target->getId();
|
||||||
|
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
$client->request('PATCH', '/api/users/'.$targetId.'/rbac', [
|
||||||
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||||
|
'json' => [
|
||||||
|
'username' => 'test_target_renamed',
|
||||||
|
'isAdmin' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$em = $this->getEm();
|
||||||
|
$em->clear();
|
||||||
|
|
||||||
|
/** @var User $reloaded */
|
||||||
|
$reloaded = $em->getRepository(User::class)->find($targetId);
|
||||||
|
// `username` n'est pas dans `user:rbac:write` : ignore en denormalization.
|
||||||
|
self::assertSame('test_target', $reloaded->getUsername());
|
||||||
|
// `isAdmin` est bien applique.
|
||||||
|
self::assertTrue($reloaded->isAdmin());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchProfileEndpointDoesNotModifyIsAdmin(): void
|
||||||
|
{
|
||||||
|
// Confirme la decision 0fc4e16 : `isAdmin` n'est plus dans `user:write`,
|
||||||
|
// donc `PATCH /api/users/{id}` sans `/rbac` ne peut plus promouvoir.
|
||||||
|
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
||||||
|
self::assertNotNull($target);
|
||||||
|
$targetId = $target->getId();
|
||||||
|
self::assertFalse($target->isAdmin());
|
||||||
|
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
$client->request('PATCH', '/api/users/'.$targetId, [
|
||||||
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||||
|
'json' => ['isAdmin' => true],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Peu importe le code : le champ ne doit tout simplement pas bouger.
|
||||||
|
$em = $this->getEm();
|
||||||
|
$em->clear();
|
||||||
|
|
||||||
|
/** @var User $reloaded */
|
||||||
|
$reloaded = $em->getRepository(User::class)->find($targetId);
|
||||||
|
self::assertFalse($reloaded->isAdmin());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchRbacSelfRemovingAdminReturns400(): void
|
||||||
|
{
|
||||||
|
// On utilise le user admin dedie (test_self_admin) pour ne pas
|
||||||
|
// corrompre l'admin fixture en cas de bug.
|
||||||
|
$em = $this->getEm();
|
||||||
|
$selfAdmin = $em->getRepository(User::class)->findOneBy(['username' => 'test_self_admin']);
|
||||||
|
self::assertNotNull($selfAdmin);
|
||||||
|
$selfAdminId = $selfAdmin->getId();
|
||||||
|
|
||||||
|
$client = $this->authenticatedClient('test_self_admin', 'secret');
|
||||||
|
$client->request('PATCH', '/api/users/'.$selfAdminId.'/rbac', [
|
||||||
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||||
|
'json' => ['isAdmin' => false],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(400);
|
||||||
|
|
||||||
|
$em = $this->getEm();
|
||||||
|
$em->clear();
|
||||||
|
|
||||||
|
/** @var User $reloaded */
|
||||||
|
$reloaded = $em->getRepository(User::class)->find($selfAdminId);
|
||||||
|
self::assertTrue($reloaded->isAdmin());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cleanupTestData(): void
|
||||||
|
{
|
||||||
|
$em = $this->getEm();
|
||||||
|
|
||||||
|
// Ordre important : delier les collections avant de supprimer les
|
||||||
|
// entites referencees pour que les FK cascade s'appliquent via le
|
||||||
|
// schema PostgreSQL.
|
||||||
|
$em->createQuery(
|
||||||
|
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix'
|
||||||
|
)->setParameter('prefix', self::TEST_USER_PREFIX.'%')->execute();
|
||||||
|
|
||||||
|
$em->createQuery(
|
||||||
|
'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix'
|
||||||
|
)->setParameter('prefix', self::TEST_ROLE_PREFIX.'%')->execute();
|
||||||
|
|
||||||
|
$em->createQuery(
|
||||||
|
'DELETE FROM '.Permission::class.' p WHERE p.code LIKE :prefix'
|
||||||
|
)->setParameter('prefix', self::TEST_PERMISSION_PREFIX.'%')->execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Core\Infrastructure\ApiPlatform\State\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
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\ApiPlatform\State\Processor\UserRbacProcessor;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\ORM\UnitOfWork;
|
||||||
|
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionClass;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests unitaires du UserRbacProcessor : couvre la garde "auto-suicide" et la
|
||||||
|
* delegation au PersistProcessor Doctrine decore pour les trois champs RBAC
|
||||||
|
* (isAdmin, roles, directPermissions).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
#[AllowMockObjectsWithoutExpectations]
|
||||||
|
final class UserRbacProcessorTest extends TestCase
|
||||||
|
{
|
||||||
|
private MockObject&ProcessorInterface $persistProcessor;
|
||||||
|
private EntityManagerInterface&MockObject $entityManager;
|
||||||
|
private MockObject&UnitOfWork $unitOfWork;
|
||||||
|
private MockObject&Security $security;
|
||||||
|
private UserRbacProcessor $processor;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->persistProcessor = $this->createMock(ProcessorInterface::class);
|
||||||
|
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
|
$this->unitOfWork = $this->createMock(UnitOfWork::class);
|
||||||
|
$this->security = $this->createMock(Security::class);
|
||||||
|
|
||||||
|
$this->entityManager->method('getUnitOfWork')->willReturn($this->unitOfWork);
|
||||||
|
|
||||||
|
$this->processor = new UserRbacProcessor(
|
||||||
|
$this->persistProcessor,
|
||||||
|
$this->entityManager,
|
||||||
|
$this->security,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchPromotesUserToAdminDelegatesToPersistProcessor(): void
|
||||||
|
{
|
||||||
|
$target = $this->buildUser(42, 'alice', false);
|
||||||
|
$target->setIsAdmin(true);
|
||||||
|
|
||||||
|
$currentAdmin = $this->buildUser(1, 'admin', true);
|
||||||
|
$this->security->method('getUser')->willReturn($currentAdmin);
|
||||||
|
|
||||||
|
// Cible != user courant : pas de lecture d'UnitOfWork necessaire.
|
||||||
|
$this->unitOfWork->expects(self::never())->method('getOriginalEntityData');
|
||||||
|
|
||||||
|
$this->persistProcessor
|
||||||
|
->expects(self::once())
|
||||||
|
->method('process')
|
||||||
|
->with($target)
|
||||||
|
->willReturn($target)
|
||||||
|
;
|
||||||
|
|
||||||
|
$result = $this->processor->process($target, new Patch());
|
||||||
|
|
||||||
|
self::assertSame($target, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchUpdatesRolesCollectionDelegatesToPersistProcessor(): void
|
||||||
|
{
|
||||||
|
$target = $this->buildUser(42, 'alice', false);
|
||||||
|
$target->addRbacRole(new Role('editor', 'Editor', false));
|
||||||
|
|
||||||
|
$currentAdmin = $this->buildUser(1, 'admin', true);
|
||||||
|
$this->security->method('getUser')->willReturn($currentAdmin);
|
||||||
|
|
||||||
|
$this->persistProcessor
|
||||||
|
->expects(self::once())
|
||||||
|
->method('process')
|
||||||
|
->with($target)
|
||||||
|
->willReturn($target)
|
||||||
|
;
|
||||||
|
|
||||||
|
$result = $this->processor->process($target, new Patch());
|
||||||
|
|
||||||
|
self::assertSame($target, $result);
|
||||||
|
self::assertCount(1, $result->getRbacRoles());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchUpdatesDirectPermissionsCollectionDelegatesToPersistProcessor(): void
|
||||||
|
{
|
||||||
|
$target = $this->buildUser(42, 'alice', false);
|
||||||
|
$target->addDirectPermission(new Permission('core.users.view', 'View', 'core'));
|
||||||
|
|
||||||
|
$currentAdmin = $this->buildUser(1, 'admin', true);
|
||||||
|
$this->security->method('getUser')->willReturn($currentAdmin);
|
||||||
|
|
||||||
|
$this->persistProcessor
|
||||||
|
->expects(self::once())
|
||||||
|
->method('process')
|
||||||
|
->with($target)
|
||||||
|
->willReturn($target)
|
||||||
|
;
|
||||||
|
|
||||||
|
$result = $this->processor->process($target, new Patch());
|
||||||
|
|
||||||
|
self::assertSame($target, $result);
|
||||||
|
self::assertCount(1, $result->getDirectPermissions());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchSelfRemovingAdminThrowsBadRequestHttpException(): void
|
||||||
|
{
|
||||||
|
// Meme identifiant : l'user courant PATCH sa propre ressource.
|
||||||
|
$self = $this->buildUser(1, 'admin', false);
|
||||||
|
|
||||||
|
$this->security->method('getUser')->willReturn($self);
|
||||||
|
|
||||||
|
$this->unitOfWork
|
||||||
|
->expects(self::once())
|
||||||
|
->method('getOriginalEntityData')
|
||||||
|
->with($self)
|
||||||
|
->willReturn([
|
||||||
|
'id' => 1,
|
||||||
|
'username' => 'admin',
|
||||||
|
'isAdmin' => true,
|
||||||
|
])
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->persistProcessor->expects(self::never())->method('process');
|
||||||
|
|
||||||
|
$this->expectException(BadRequestHttpException::class);
|
||||||
|
$this->expectExceptionMessage('Vous ne pouvez pas retirer vos propres droits administrateur.');
|
||||||
|
|
||||||
|
$this->processor->process($self, new Patch());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchAdminDemotingAnotherUserIsAllowed(): void
|
||||||
|
{
|
||||||
|
// Un admin qui retire isAdmin a quelqu'un d'autre : autorise.
|
||||||
|
$target = $this->buildUser(42, 'alice', false);
|
||||||
|
$current = $this->buildUser(1, 'admin', true);
|
||||||
|
|
||||||
|
$this->security->method('getUser')->willReturn($current);
|
||||||
|
|
||||||
|
// Cible != user courant : pas de verification d'auto-suicide.
|
||||||
|
$this->unitOfWork->expects(self::never())->method('getOriginalEntityData');
|
||||||
|
|
||||||
|
$this->persistProcessor
|
||||||
|
->expects(self::once())
|
||||||
|
->method('process')
|
||||||
|
->with($target)
|
||||||
|
->willReturn($target)
|
||||||
|
;
|
||||||
|
|
||||||
|
$result = $this->processor->process($target, new Patch());
|
||||||
|
|
||||||
|
self::assertSame($target, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchSelfKeepingAdminIsAllowed(): void
|
||||||
|
{
|
||||||
|
// L'user courant se PATCH lui-meme mais garde isAdmin = true :
|
||||||
|
// aucun auto-suicide, on delegue au PersistProcessor.
|
||||||
|
$self = $this->buildUser(1, 'admin', true);
|
||||||
|
|
||||||
|
$this->security->method('getUser')->willReturn($self);
|
||||||
|
|
||||||
|
$this->unitOfWork
|
||||||
|
->expects(self::once())
|
||||||
|
->method('getOriginalEntityData')
|
||||||
|
->with($self)
|
||||||
|
->willReturn([
|
||||||
|
'id' => 1,
|
||||||
|
'username' => 'admin',
|
||||||
|
'isAdmin' => true,
|
||||||
|
])
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->persistProcessor
|
||||||
|
->expects(self::once())
|
||||||
|
->method('process')
|
||||||
|
->with($self)
|
||||||
|
->willReturn($self)
|
||||||
|
;
|
||||||
|
|
||||||
|
$result = $this->processor->process($self, new Patch());
|
||||||
|
|
||||||
|
self::assertSame($self, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit un User avec un id force via reflection (les mocks
|
||||||
|
* d'UnitOfWork n'alimentent pas l'id tout seul).
|
||||||
|
*/
|
||||||
|
private function buildUser(int $id, string $username, bool $isAdmin): User
|
||||||
|
{
|
||||||
|
$user = new User();
|
||||||
|
$user->setUsername($username);
|
||||||
|
$user->setIsAdmin($isAdmin);
|
||||||
|
|
||||||
|
$refl = new ReflectionClass($user);
|
||||||
|
$prop = $refl->getProperty('id');
|
||||||
|
$prop->setAccessible(true);
|
||||||
|
$prop->setValue($user, $id);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user