RBAC - Système complet de permissions (Backend + Frontend) (#7)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
## Résumé
Implémentation complète du système RBAC (Role-Based Access Control) pour Coltura.
### Backend
- Entités Permission et Role avec API Platform CRUD
- PermissionVoter : vérification des permissions effectives (rôles + directes), admin bypass
- Endpoints `PATCH /users/{id}/rbac` pour assigner rôles, permissions directes et isAdmin
- AdminHeadcountGuard : protection contre la suppression du dernier admin
- Commande `app:sync-permissions` pour synchroniser les permissions déclarées par les modules
- Filtrage sidebar par permission RBAC (`permission` key optionnelle dans sidebar.php)
- 115 tests PHPUnit (fonctionnels + unitaires)
### Frontend
- Composable `usePermissions()` avec `can()`, `canAny()`, `canAll()` et admin bypass
- Page `/admin/roles` : DataTable, création/édition via drawer, suppression avec confirmation
- Page `/admin/users` : DataTable, drawer RBAC avec rôles, permissions directes, résumé effectif
- PermissionGroup : checkboxes groupées par module avec "tout sélectionner"
- EffectivePermissions : résumé lecture seule avec badges source ("via Rôle X" / "Direct")
- Warning auto-édition, toggle isAdmin
- Tests Vitest pour usePermissions
### Permissions déclarées
- `core.users.view` — Voir les utilisateurs
- `core.users.manage` — Gérer les utilisateurs
- `core.roles.view` — Voir les rôles RBAC
- `core.roles.manage` — Gérer les rôles et permissions
- `GET /api/permissions` accessible à tout utilisateur authentifié (catalogue read-only)
## Tickets Lesstime
- ERP-23 (#343) — Entités Permission et Role
- ERP-24 (#344) — API CRUD Roles & Permissions
- ERP-25 (#345) — Voter Symfony + usePermissions
- ERP-26 (#346) — Interface Admin : Gestion des Rôles
- ERP-27 (#347) — Interface Admin : Permissions Utilisateur
## Test plan
- [ ] `make db-reset` puis vérifier les fixtures (admin/alice/bob, rôles système)
- [ ] Login admin : sidebar affiche Gestion des rôles + Utilisateurs
- [ ] Login alice : sidebar masque ces onglets (pas de permission)
- [ ] Page /admin/roles : CRUD rôles, permissions groupées, protection rôles système
- [ ] Page /admin/users : assignation rôles + permissions directes, résumé effectif
- [ ] Warning auto-édition quand admin modifie ses propres droits
- [ ] `make test` : 115 tests PHPUnit passent
- [ ] `cd frontend && npm run test` : tests Vitest passent
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Matthieu <mtholot19@gmail.com>
Co-authored-by: tristan <tristan@yuno.malio.fr>
Reviewed-on: #7
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #7.
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Core\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\UnitOfWork;
|
||||
use LogicException;
|
||||
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
use stdClass;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Tests unitaires du RoleProcessor : couvre les gardes metier
|
||||
* (immuabilite du code, refus de suppression des roles systeme) et la
|
||||
* delegation aux processors Doctrine decores.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
#[AllowMockObjectsWithoutExpectations]
|
||||
final class RoleProcessorTest extends TestCase
|
||||
{
|
||||
private MockObject&ProcessorInterface $persistProcessor;
|
||||
private MockObject&ProcessorInterface $removeProcessor;
|
||||
private EntityManagerInterface&MockObject $entityManager;
|
||||
private MockObject&UnitOfWork $unitOfWork;
|
||||
private RoleProcessor $processor;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->persistProcessor = $this->createMock(ProcessorInterface::class);
|
||||
$this->removeProcessor = $this->createMock(ProcessorInterface::class);
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$this->unitOfWork = $this->createMock(UnitOfWork::class);
|
||||
|
||||
$this->entityManager->method('getUnitOfWork')->willReturn($this->unitOfWork);
|
||||
|
||||
$this->processor = new RoleProcessor(
|
||||
$this->persistProcessor,
|
||||
$this->removeProcessor,
|
||||
$this->entityManager,
|
||||
);
|
||||
}
|
||||
|
||||
public function testDeleteCustomRoleDelegatesToRemoveProcessor(): void
|
||||
{
|
||||
$role = new Role('editor', 'Editor', false);
|
||||
|
||||
$this->removeProcessor
|
||||
->expects(self::once())
|
||||
->method('process')
|
||||
->with($role)
|
||||
->willReturn(null)
|
||||
;
|
||||
|
||||
$this->persistProcessor->expects(self::never())->method('process');
|
||||
|
||||
$result = $this->processor->process($role, new Delete());
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
public function testDeleteSystemRoleThrowsAccessDeniedHttpException(): void
|
||||
{
|
||||
$role = new Role('admin', 'Admin', true);
|
||||
|
||||
$this->removeProcessor->expects(self::never())->method('process');
|
||||
$this->persistProcessor->expects(self::never())->method('process');
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$this->expectExceptionMessage('Le role systeme "admin" ne peut pas etre supprime.');
|
||||
|
||||
$this->processor->process($role, new Delete());
|
||||
}
|
||||
|
||||
public function testPostCreatesCustomRoleDelegatesToPersistProcessor(): void
|
||||
{
|
||||
$role = new Role('editor', 'Editor', false);
|
||||
|
||||
// Entite nouvelle : l'UnitOfWork n'a pas d'etat d'origine.
|
||||
$this->unitOfWork
|
||||
->expects(self::once())
|
||||
->method('getOriginalEntityData')
|
||||
->with($role)
|
||||
->willReturn([])
|
||||
;
|
||||
|
||||
$this->persistProcessor
|
||||
->expects(self::once())
|
||||
->method('process')
|
||||
->with($role)
|
||||
->willReturn($role)
|
||||
;
|
||||
|
||||
$this->removeProcessor->expects(self::never())->method('process');
|
||||
|
||||
$result = $this->processor->process($role, new Post());
|
||||
|
||||
self::assertSame($role, $result);
|
||||
}
|
||||
|
||||
public function testPatchWithChangedCodeThrowsBadRequestHttpException(): void
|
||||
{
|
||||
// L'entite arrive avec le nouveau code deja applique par le denormalizer.
|
||||
$role = new Role('editor_renamed', 'Editor', false);
|
||||
$this->setRoleId($role, 42);
|
||||
|
||||
$this->unitOfWork
|
||||
->expects(self::once())
|
||||
->method('getOriginalEntityData')
|
||||
->with($role)
|
||||
->willReturn([
|
||||
'id' => 42,
|
||||
'code' => 'editor',
|
||||
'label' => 'Editor',
|
||||
'isSystem' => false,
|
||||
])
|
||||
;
|
||||
|
||||
$this->persistProcessor->expects(self::never())->method('process');
|
||||
$this->removeProcessor->expects(self::never())->method('process');
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage("Le code d'un role est immuable apres creation.");
|
||||
|
||||
$this->processor->process($role, new Patch());
|
||||
}
|
||||
|
||||
public function testPatchWithUnchangedCodeDelegatesToPersistProcessor(): void
|
||||
{
|
||||
$role = new Role('editor', 'Editor modifie', false, 'desc');
|
||||
$this->setRoleId($role, 42);
|
||||
|
||||
$this->unitOfWork
|
||||
->expects(self::once())
|
||||
->method('getOriginalEntityData')
|
||||
->with($role)
|
||||
->willReturn([
|
||||
'id' => 42,
|
||||
'code' => 'editor',
|
||||
'label' => 'Editor',
|
||||
'isSystem' => false,
|
||||
])
|
||||
;
|
||||
|
||||
$this->persistProcessor
|
||||
->expects(self::once())
|
||||
->method('process')
|
||||
->with($role)
|
||||
->willReturn($role)
|
||||
;
|
||||
|
||||
$this->removeProcessor->expects(self::never())->method('process');
|
||||
|
||||
$result = $this->processor->process($role, new Patch());
|
||||
|
||||
self::assertSame($role, $result);
|
||||
}
|
||||
|
||||
public function testPatchSystemRoleLabelDelegatesToPersistProcessor(): void
|
||||
{
|
||||
// Regle uniforme : un role systeme peut voir son label modifie tant
|
||||
// que son code reste inchange. Seul le DELETE est bloque.
|
||||
$role = new Role('admin', 'Administrateur', true);
|
||||
$this->setRoleId($role, 1);
|
||||
|
||||
$this->unitOfWork
|
||||
->expects(self::once())
|
||||
->method('getOriginalEntityData')
|
||||
->with($role)
|
||||
->willReturn([
|
||||
'id' => 1,
|
||||
'code' => 'admin',
|
||||
'label' => 'Admin',
|
||||
'isSystem' => true,
|
||||
])
|
||||
;
|
||||
|
||||
$this->persistProcessor
|
||||
->expects(self::once())
|
||||
->method('process')
|
||||
->with($role)
|
||||
->willReturn($role)
|
||||
;
|
||||
|
||||
$this->removeProcessor->expects(self::never())->method('process');
|
||||
|
||||
$result = $this->processor->process($role, new Patch());
|
||||
|
||||
self::assertSame($role, $result);
|
||||
}
|
||||
|
||||
public function testProcessNonRoleDataThrowsLogicException(): void
|
||||
{
|
||||
// Garde-fou contre une misconfiguration : ce processor est wire
|
||||
// exclusivement sur les operations Role.
|
||||
$this->persistProcessor->expects(self::never())->method('process');
|
||||
$this->removeProcessor->expects(self::never())->method('process');
|
||||
|
||||
$this->expectException(LogicException::class);
|
||||
$this->expectExceptionMessage('RoleProcessor attend une instance de');
|
||||
|
||||
$this->processor->process(new stdClass(), new Patch());
|
||||
}
|
||||
|
||||
/**
|
||||
* Positionne l'id d'un Role via reflection pour simuler une entite deja
|
||||
* persistee (les mocks d'UnitOfWork n'alimentent pas l'id tout seul).
|
||||
*/
|
||||
private function setRoleId(Role $role, int $id): void
|
||||
{
|
||||
$refl = new ReflectionClass($role);
|
||||
$prop = $refl->getProperty('id');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue($role, $id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Core\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor;
|
||||
use LogicException;
|
||||
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Tests unitaires du UserProcessor : couvre la garde "dernier admin global"
|
||||
* et la delegation au RemoveProcessor Doctrine decore pour l'operation DELETE.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
#[AllowMockObjectsWithoutExpectations]
|
||||
final class UserProcessorTest extends TestCase
|
||||
{
|
||||
private MockObject&ProcessorInterface $removeProcessor;
|
||||
private AdminHeadcountGuardInterface&MockObject $adminHeadcountGuard;
|
||||
private UserProcessor $processor;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->removeProcessor = $this->createMock(ProcessorInterface::class);
|
||||
$this->adminHeadcountGuard = $this->createMock(AdminHeadcountGuardInterface::class);
|
||||
|
||||
$this->processor = new UserProcessor(
|
||||
$this->removeProcessor,
|
||||
$this->adminHeadcountGuard,
|
||||
);
|
||||
}
|
||||
|
||||
public function testDelegatesWhenUserIsNotAdmin(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername('alice');
|
||||
$user->setIsAdmin(false);
|
||||
|
||||
// La garde ne doit jamais etre appellee pour un non-admin.
|
||||
$this->adminHeadcountGuard
|
||||
->expects($this->never())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDeletion')
|
||||
;
|
||||
|
||||
$this->removeProcessor
|
||||
->expects($this->once())
|
||||
->method('process')
|
||||
->with($user)
|
||||
->willReturn(null)
|
||||
;
|
||||
|
||||
$result = $this->processor->process($user, new Delete());
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
public function testDelegatesWhenAdminButNotLast(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername('admin');
|
||||
$user->setIsAdmin(true);
|
||||
|
||||
// La garde est appelee et ne leve pas d'exception (il reste d'autres admins).
|
||||
$this->adminHeadcountGuard
|
||||
->expects($this->once())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDeletion')
|
||||
->with($user)
|
||||
;
|
||||
|
||||
$this->removeProcessor
|
||||
->expects($this->once())
|
||||
->method('process')
|
||||
->with($user)
|
||||
->willReturn(null)
|
||||
;
|
||||
|
||||
$this->processor->process($user, new Delete());
|
||||
}
|
||||
|
||||
public function testBlocksWhenDeletingLastAdmin(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername('admin');
|
||||
$user->setIsAdmin(true);
|
||||
|
||||
$exceptionMessage = 'Impossible : au moins un administrateur doit rester sur l\'instance.';
|
||||
|
||||
$this->adminHeadcountGuard
|
||||
->expects($this->once())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDeletion')
|
||||
->with($user)
|
||||
->willThrowException(new LastAdminProtectionException($exceptionMessage))
|
||||
;
|
||||
|
||||
// La suppression ne doit pas etre executee si la garde echoue.
|
||||
$this->removeProcessor
|
||||
->expects($this->never())
|
||||
->method('process')
|
||||
;
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage($exceptionMessage);
|
||||
|
||||
$this->processor->process($user, new Delete());
|
||||
}
|
||||
|
||||
public function testFailFastOnInvalidDataType(): void
|
||||
{
|
||||
// Garde-fou contre une misconfiguration : ce processor est wire
|
||||
// exclusivement sur l'operation Delete de User.
|
||||
$this->adminHeadcountGuard->expects($this->never())->method('ensureAtLeastOneAdminRemainsAfterDeletion');
|
||||
$this->removeProcessor->expects($this->never())->method('process');
|
||||
|
||||
$this->expectException(LogicException::class);
|
||||
$this->expectExceptionMessage('UserProcessor attend une instance de');
|
||||
|
||||
$this->processor->process(new stdClass(), new Delete());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
<?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\Domain\Exception\LastAdminProtectionException;
|
||||
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\UnitOfWork;
|
||||
use LogicException;
|
||||
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
use stdClass;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Tests unitaires du UserRbacProcessor : couvre la garde "auto-suicide", la
|
||||
* garde "dernier admin global" 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 AdminHeadcountGuardInterface&MockObject $adminHeadcountGuard;
|
||||
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->adminHeadcountGuard = $this->createMock(AdminHeadcountGuardInterface::class);
|
||||
|
||||
$this->entityManager->method('getUnitOfWork')->willReturn($this->unitOfWork);
|
||||
|
||||
$this->processor = new UserRbacProcessor(
|
||||
$this->persistProcessor,
|
||||
$this->entityManager,
|
||||
$this->security,
|
||||
$this->adminHeadcountGuard,
|
||||
);
|
||||
}
|
||||
|
||||
public function testPatchPromotesUserToAdminDelegatesToPersistProcessor(): void
|
||||
{
|
||||
$target = $this->buildUser(42, 'alice', true);
|
||||
|
||||
$currentAdmin = $this->buildUser(1, 'admin', true);
|
||||
$this->security->method('getUser')->willReturn($currentAdmin);
|
||||
|
||||
// La cible gagne isAdmin (false -> true) : willLoseAdmin = false, donc
|
||||
// getOriginalEntityData est appele mais aucune garde ne bloque.
|
||||
$this->unitOfWork
|
||||
->method('getOriginalEntityData')
|
||||
->with($target)
|
||||
->willReturn([
|
||||
'id' => 42,
|
||||
'username' => 'alice',
|
||||
'isAdmin' => false,
|
||||
])
|
||||
;
|
||||
|
||||
$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 si d'autres
|
||||
// admins existent (guard ne leve pas d'exception).
|
||||
$target = $this->buildUser(42, 'alice', false);
|
||||
$current = $this->buildUser(1, 'admin', true);
|
||||
|
||||
$this->security->method('getUser')->willReturn($current);
|
||||
|
||||
// La cible perd isAdmin (true -> false) : getOriginalEntityData est appele.
|
||||
$this->unitOfWork
|
||||
->method('getOriginalEntityData')
|
||||
->with($target)
|
||||
->willReturn([
|
||||
'id' => 42,
|
||||
'username' => 'alice',
|
||||
'isAdmin' => true,
|
||||
])
|
||||
;
|
||||
|
||||
// Le garde ne leve pas d'exception : d'autres admins existent.
|
||||
$this->adminHeadcountGuard
|
||||
->expects(self::once())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDemotion')
|
||||
->with($target)
|
||||
;
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
public function testProcessNonUserDataThrowsLogicException(): void
|
||||
{
|
||||
// Garde-fou contre une misconfiguration : ce processor est wire
|
||||
// exclusivement sur l'operation user_rbac_patch (cible User).
|
||||
$this->persistProcessor->expects(self::never())->method('process');
|
||||
|
||||
$this->expectException(LogicException::class);
|
||||
$this->expectExceptionMessage('UserRbacProcessor attend une instance de');
|
||||
|
||||
$this->processor->process(new stdClass(), new Patch());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tests de la garde "dernier admin global"
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testBlocksDemotionWhenLastAdminGlobally(): void
|
||||
{
|
||||
// L'admin courant A tente de retirer isAdmin a l'admin B (le dernier).
|
||||
$adminA = $this->buildUser(1, 'adminA', true);
|
||||
$adminB = $this->buildUser(2, 'adminB', false); // isAdmin -> false dans le PATCH
|
||||
|
||||
$this->security->method('getUser')->willReturn($adminA);
|
||||
|
||||
$this->unitOfWork
|
||||
->method('getOriginalEntityData')
|
||||
->with($adminB)
|
||||
->willReturn([
|
||||
'id' => 2,
|
||||
'username' => 'adminB',
|
||||
'isAdmin' => true,
|
||||
])
|
||||
;
|
||||
|
||||
// Le garde signale qu'il n'y aurait plus aucun admin.
|
||||
$this->adminHeadcountGuard
|
||||
->expects(self::once())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDemotion')
|
||||
->with($adminB)
|
||||
->willThrowException(new LastAdminProtectionException())
|
||||
;
|
||||
|
||||
$this->persistProcessor->expects(self::never())->method('process');
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage('Impossible : au moins un administrateur doit rester sur l\'instance.');
|
||||
|
||||
$this->processor->process($adminB, new Patch());
|
||||
}
|
||||
|
||||
public function testDelegatesDemotionWhenAdminsRemain(): void
|
||||
{
|
||||
// L'admin courant A retire isAdmin a l'admin B, mais d'autres admins existent.
|
||||
$adminA = $this->buildUser(1, 'adminA', true);
|
||||
$adminB = $this->buildUser(2, 'adminB', false); // isAdmin -> false dans le PATCH
|
||||
|
||||
$this->security->method('getUser')->willReturn($adminA);
|
||||
|
||||
$this->unitOfWork
|
||||
->method('getOriginalEntityData')
|
||||
->with($adminB)
|
||||
->willReturn([
|
||||
'id' => 2,
|
||||
'username' => 'adminB',
|
||||
'isAdmin' => true,
|
||||
])
|
||||
;
|
||||
|
||||
// Le garde ne leve pas d'exception : il reste au moins un admin.
|
||||
$this->adminHeadcountGuard
|
||||
->expects(self::once())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDemotion')
|
||||
->with($adminB)
|
||||
;
|
||||
|
||||
$this->persistProcessor
|
||||
->expects(self::once())
|
||||
->method('process')
|
||||
->with($adminB)
|
||||
->willReturn($adminB)
|
||||
;
|
||||
|
||||
$result = $this->processor->process($adminB, new Patch());
|
||||
|
||||
self::assertSame($adminB, $result);
|
||||
}
|
||||
|
||||
public function testDoesNotCallGuardWhenIsAdminUntouched(): void
|
||||
{
|
||||
// PATCH qui ne touche pas isAdmin (reste false) : la garde ne doit pas etre appelee.
|
||||
$target = $this->buildUser(42, 'alice', false);
|
||||
$current = $this->buildUser(1, 'admin', true);
|
||||
|
||||
$this->security->method('getUser')->willReturn($current);
|
||||
|
||||
$this->unitOfWork
|
||||
->method('getOriginalEntityData')
|
||||
->with($target)
|
||||
->willReturn([
|
||||
'id' => 42,
|
||||
'username' => 'alice',
|
||||
'isAdmin' => false,
|
||||
])
|
||||
;
|
||||
|
||||
// isAdmin reste false : willLoseAdmin = false, garde jamais appelee.
|
||||
$this->adminHeadcountGuard
|
||||
->expects(self::never())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDemotion')
|
||||
;
|
||||
|
||||
$this->persistProcessor
|
||||
->expects(self::once())
|
||||
->method('process')
|
||||
->with($target)
|
||||
->willReturn($target)
|
||||
;
|
||||
|
||||
$result = $this->processor->process($target, new Patch());
|
||||
|
||||
self::assertSame($target, $result);
|
||||
}
|
||||
|
||||
public function testAutoSuicideTakesPrecedenceOverLastAdminGlobal(): void
|
||||
{
|
||||
// L'unique admin tente de se retirer lui-meme son propre flag.
|
||||
// La garde auto-suicide doit court-circuiter avant la garde dernier-admin.
|
||||
$self = $this->buildUser(1, 'admin', false); // isAdmin -> false dans le PATCH
|
||||
|
||||
$this->security->method('getUser')->willReturn($self);
|
||||
|
||||
$this->unitOfWork
|
||||
->method('getOriginalEntityData')
|
||||
->with($self)
|
||||
->willReturn([
|
||||
'id' => 1,
|
||||
'username' => 'admin',
|
||||
'isAdmin' => true,
|
||||
])
|
||||
;
|
||||
|
||||
// La garde dernier-admin ne doit jamais etre appelee : l'auto-suicide
|
||||
// court-circuite avant.
|
||||
$this->adminHeadcountGuard
|
||||
->expects(self::never())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDemotion')
|
||||
;
|
||||
|
||||
$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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\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\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
|
||||
use Symfony\Component\Security\Core\User\InMemoryUser;
|
||||
|
||||
/**
|
||||
* Tests unitaires du PermissionVoter RBAC.
|
||||
*
|
||||
* Le voter est teste via sa methode publique vote() qui retourne une des
|
||||
* trois constantes VoterInterface : ACCESS_GRANTED, ACCESS_DENIED, ACCESS_ABSTAIN.
|
||||
* - ACCESS_ABSTAIN : supports() a retourne false (attribut non-RBAC).
|
||||
* - ACCESS_GRANTED / ACCESS_DENIED : voteOnAttribute() a ete invoque.
|
||||
*
|
||||
* Aucun acces base de donnees : toutes les entites sont construites en memoire.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class PermissionVoterTest extends TestCase
|
||||
{
|
||||
private PermissionVoter $voter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->voter = new PermissionVoter();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Abstention : attributs non-RBAC
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Le voter s'abstient sur ROLE_ADMIN : commence par une majuscule,
|
||||
* ne correspond pas au pattern snake_case minuscule avec point.
|
||||
*/
|
||||
public function testAbstainsOnRoleAdminAttribute(): void
|
||||
{
|
||||
$user = $this->buildUser(username: 'alice', isAdmin: false);
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
$result = $this->voter->vote($token, null, ['ROLE_ADMIN']);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_ABSTAIN, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le voter s'abstient sur IS_AUTHENTICATED_FULLY : contient des majuscules,
|
||||
* pas de point de separation conforme au pattern RBAC.
|
||||
*/
|
||||
public function testAbstainsOnIsAuthenticatedAttribute(): void
|
||||
{
|
||||
$user = $this->buildUser(username: 'alice', isAdmin: false);
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
$result = $this->voter->vote($token, null, ['IS_AUTHENTICATED_FULLY']);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_ABSTAIN, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le voter s'abstient sur des attributs malformes : sans point ou avec
|
||||
* majuscules.
|
||||
*/
|
||||
#[DataProvider('malformedAttributeProvider')]
|
||||
public function testAbstainsOnMalformedAttribute(string $attribute): void
|
||||
{
|
||||
$user = $this->buildUser(username: 'alice', isAdmin: false);
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
$result = $this->voter->vote($token, null, [$attribute]);
|
||||
|
||||
$this->assertSame(
|
||||
VoterInterface::ACCESS_ABSTAIN,
|
||||
$result,
|
||||
sprintf('Le voter aurait du s\'abstenir pour l\'attribut "%s".', $attribute),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function malformedAttributeProvider(): array
|
||||
{
|
||||
return [
|
||||
'sans point' => ['nodot'],
|
||||
'majuscule milieu' => ['HAS.UPPERCASE'],
|
||||
'commence chiffre' => ['1core.users.view'],
|
||||
'chaine vide' => [''],
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Refus : utilisateur non reconnu
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Refuse l'acces quand le token ne porte pas une instance de User metier
|
||||
* (ex: InMemoryUser de Symfony).
|
||||
*/
|
||||
public function testDeniesWhenUserIsNotAUserEntity(): void
|
||||
{
|
||||
$inMemoryUser = new InMemoryUser('anonymous', null, ['ROLE_USER']);
|
||||
$token = new UsernamePasswordToken($inMemoryUser, 'main', $inMemoryUser->getRoles());
|
||||
|
||||
$result = $this->voter->vote($token, null, ['core.users.view']);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Bypass admin
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Accorde l'acces systematiquement a un administrateur, meme sans aucune
|
||||
* permission explicite assignee.
|
||||
*/
|
||||
public function testGrantsForAdminBypass(): void
|
||||
{
|
||||
// Admin sans role ni permission directe : le bypass doit suffire.
|
||||
$user = $this->buildUser(username: 'admin', isAdmin: true);
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
$result = $this->voter->vote($token, null, ['core.users.view']);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Permissions effectives via role
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Accorde l'acces quand l'utilisateur possede la permission exacte via un role.
|
||||
*/
|
||||
public function testGrantsWhenUserHasExactPermission(): void
|
||||
{
|
||||
$permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core');
|
||||
$role = new Role('viewer', 'Viewer');
|
||||
$role->addPermission($permission);
|
||||
|
||||
$user = $this->buildUser(username: 'alice', isAdmin: false);
|
||||
$user->addRbacRole($role);
|
||||
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
$result = $this->voter->vote($token, null, ['core.users.view']);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse l'acces quand l'utilisateur possede une permission differente de
|
||||
* celle demandee.
|
||||
*/
|
||||
public function testDeniesWhenUserLacksPermission(): void
|
||||
{
|
||||
$permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core');
|
||||
$role = new Role('viewer', 'Viewer');
|
||||
$role->addPermission($permission);
|
||||
|
||||
$user = $this->buildUser(username: 'alice', isAdmin: false);
|
||||
$user->addRbacRole($role);
|
||||
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
// L'utilisateur a core.users.view mais pas core.roles.manage.
|
||||
$result = $this->voter->vote($token, null, ['core.roles.manage']);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Permissions directes (hors roles)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Accorde l'acces via une permission directe (assignee sans passer par un role).
|
||||
*/
|
||||
public function testGrantsForDirectPermission(): void
|
||||
{
|
||||
$permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core');
|
||||
|
||||
$user = $this->buildUser(username: 'bob', isAdmin: false);
|
||||
$user->addDirectPermission($permission);
|
||||
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
$result = $this->voter->vote($token, null, ['core.users.view']);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Construit un User metier minimal sans persistance.
|
||||
*/
|
||||
private function buildUser(string $username, bool $isAdmin): User
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername($username);
|
||||
$user->setIsAdmin($isAdmin);
|
||||
// Mot de passe factice pour satisfaire PasswordAuthenticatedUserInterface.
|
||||
$user->setPassword('hashed_placeholder');
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user