feat(core) : RBAC #345 - AdminHeadcountGuard domain service
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Core\Domain\Exception;
|
||||||
|
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Levee lorsqu'une operation mettrait fin a la presence d'au moins un
|
||||||
|
* administrateur sur l'instance.
|
||||||
|
*
|
||||||
|
* L'invariant "au moins un admin doit exister" est protege au niveau du
|
||||||
|
* domaine afin qu'aucun flux (API, CLI, import) ne puisse le contourner.
|
||||||
|
* La traduction HTTP (422 ou 403) est laissee a la couche infrastructure.
|
||||||
|
*/
|
||||||
|
final class LastAdminProtectionException extends DomainException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Construit l'exception avec un message par defaut ou un message fourni par l'appelant.
|
||||||
|
*/
|
||||||
|
public function __construct(string $message = 'Impossible : au moins un administrateur doit rester sur l\'instance.')
|
||||||
|
{
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,4 +13,12 @@ interface UserRepositoryInterface
|
|||||||
public function findByUsername(string $username): ?User;
|
public function findByUsername(string $username): ?User;
|
||||||
|
|
||||||
public function save(User $user): void;
|
public function save(User $user): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le nombre d'utilisateurs ayant le flag isAdmin a true.
|
||||||
|
*
|
||||||
|
* Utilise par AdminHeadcountGuard pour verifier l'invariant
|
||||||
|
* "au moins un administrateur doit rester sur l'instance".
|
||||||
|
*/
|
||||||
|
public function countAdmins(): int;
|
||||||
}
|
}
|
||||||
|
|||||||
64
src/Module/Core/Domain/Security/AdminHeadcountGuard.php
Normal file
64
src/Module/Core/Domain/Security/AdminHeadcountGuard.php
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Core\Domain\Security;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||||
|
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gardien de l'invariant domaine : l'instance doit toujours conserver
|
||||||
|
* au moins un utilisateur administrateur.
|
||||||
|
*
|
||||||
|
* Ce service est appele avant toute operation susceptible de reduire le
|
||||||
|
* nombre d'admins (retrait du flag isAdmin, suppression d'un utilisateur).
|
||||||
|
* Il compte les admins restants et leve LastAdminProtectionException si
|
||||||
|
* le seuil minimum (1) serait franchi.
|
||||||
|
*/
|
||||||
|
final class AdminHeadcountGuard
|
||||||
|
{
|
||||||
|
public function __construct(private readonly UserRepositoryInterface $userRepository) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifie qu'il restera au moins un admin apres la demote de $user.
|
||||||
|
*
|
||||||
|
* L'argument $user est accepte mais non utilise dans la logique de comptage :
|
||||||
|
* l'appelant a deja determine que cet utilisateur va perdre son statut admin ;
|
||||||
|
* le garde se contente de verifier qu'il en reste au moins un autre.
|
||||||
|
* Le parametre est conserve pour la lisibilite du site d'appel et pour
|
||||||
|
* permettre une evolution future (ex : journalisation, audit trail).
|
||||||
|
*/
|
||||||
|
public function ensureAtLeastOneAdminRemainsAfterDemotion(User $user): void
|
||||||
|
{
|
||||||
|
$this->checkAdminHeadcount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifie qu'il restera au moins un admin apres la suppression de $user.
|
||||||
|
*
|
||||||
|
* Meme principe que ensureAtLeastOneAdminRemainsAfterDemotion() : $user
|
||||||
|
* est accepte pour la symetrie du contrat et les evolutions futures,
|
||||||
|
* mais le comptage ne depend pas de son identite.
|
||||||
|
*/
|
||||||
|
public function ensureAtLeastOneAdminRemainsAfterDeletion(User $user): void
|
||||||
|
{
|
||||||
|
$this->checkAdminHeadcount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compte les administrateurs et leve une exception si le seuil minimum est atteint.
|
||||||
|
*
|
||||||
|
* La verification est volontairement conservative (<=1) pour couvrir
|
||||||
|
* le cas defensif ou la base serait deja dans un etat incoherent (0 admin).
|
||||||
|
*
|
||||||
|
* @throws LastAdminProtectionException si le nombre d'admins est inferieur ou egal a 1
|
||||||
|
*/
|
||||||
|
private function checkAdminHeadcount(): void
|
||||||
|
{
|
||||||
|
if ($this->userRepository->countAdmins() <= 1) {
|
||||||
|
throw new LastAdminProtectionException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,4 +34,20 @@ class DoctrineUserRepository extends ServiceEntityRepository implements UserRepo
|
|||||||
$this->getEntityManager()->persist($user);
|
$this->getEntityManager()->persist($user);
|
||||||
$this->getEntityManager()->flush();
|
$this->getEntityManager()->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compte les utilisateurs ayant le flag isAdmin a true.
|
||||||
|
*
|
||||||
|
* Utilise par AdminHeadcountGuard pour verifier que l'instance conserve
|
||||||
|
* toujours au moins un administrateur apres une demote ou une suppression.
|
||||||
|
*/
|
||||||
|
public function countAdmins(): int
|
||||||
|
{
|
||||||
|
return (int) $this->createQueryBuilder('u')
|
||||||
|
->select('COUNT(u.id)')
|
||||||
|
->where('u.isAdmin = true')
|
||||||
|
->getQuery()
|
||||||
|
->getSingleScalarResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
127
tests/Module/Core/Domain/Security/AdminHeadcountGuardTest.php
Normal file
127
tests/Module/Core/Domain/Security/AdminHeadcountGuardTest.php
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Core\Domain\Security;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||||
|
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
|
||||||
|
use App\Module\Core\Domain\Security\AdminHeadcountGuard;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests unitaires du gardien d'invariant AdminHeadcountGuard.
|
||||||
|
*
|
||||||
|
* Aucun acces base de donnees : UserRepositoryInterface est mocke.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class AdminHeadcountGuardTest extends TestCase
|
||||||
|
{
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Demote (retrait du flag admin)
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autorise la demote quand il reste plus d'un admin (cas nominal).
|
||||||
|
*/
|
||||||
|
public function testAllowsDemotionWhenMoreThanOneAdmin(): void
|
||||||
|
{
|
||||||
|
$repo = $this->createMock(UserRepositoryInterface::class);
|
||||||
|
$repo->method('countAdmins')->willReturn(2);
|
||||||
|
|
||||||
|
$guard = new AdminHeadcountGuard($repo);
|
||||||
|
$user = new User();
|
||||||
|
$user->setUsername('alice');
|
||||||
|
|
||||||
|
// Aucune exception ne doit etre levee
|
||||||
|
$guard->ensureAtLeastOneAdminRemainsAfterDemotion($user);
|
||||||
|
$this->addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bloque la demote quand il ne reste exactement qu'un admin.
|
||||||
|
*/
|
||||||
|
public function testBlocksDemotionWhenExactlyOneAdmin(): void
|
||||||
|
{
|
||||||
|
$repo = $this->createMock(UserRepositoryInterface::class);
|
||||||
|
$repo->method('countAdmins')->willReturn(1);
|
||||||
|
|
||||||
|
$guard = new AdminHeadcountGuard($repo);
|
||||||
|
$user = new User();
|
||||||
|
$user->setUsername('alice');
|
||||||
|
|
||||||
|
$this->expectException(LastAdminProtectionException::class);
|
||||||
|
$guard->ensureAtLeastOneAdminRemainsAfterDemotion($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bloque la demote de facon defensive si le compteur est a 0 (etat incoherent).
|
||||||
|
*/
|
||||||
|
public function testBlocksDemotionDefensivelyWhenZeroAdmin(): void
|
||||||
|
{
|
||||||
|
$repo = $this->createMock(UserRepositoryInterface::class);
|
||||||
|
$repo->method('countAdmins')->willReturn(0);
|
||||||
|
|
||||||
|
$guard = new AdminHeadcountGuard($repo);
|
||||||
|
$user = new User();
|
||||||
|
$user->setUsername('alice');
|
||||||
|
|
||||||
|
$this->expectException(LastAdminProtectionException::class);
|
||||||
|
$guard->ensureAtLeastOneAdminRemainsAfterDemotion($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Deletion (suppression de l'utilisateur)
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autorise la suppression quand il reste plus d'un admin (cas nominal).
|
||||||
|
*/
|
||||||
|
public function testAllowsDeletionWhenMoreThanOneAdmin(): void
|
||||||
|
{
|
||||||
|
$repo = $this->createMock(UserRepositoryInterface::class);
|
||||||
|
$repo->method('countAdmins')->willReturn(2);
|
||||||
|
|
||||||
|
$guard = new AdminHeadcountGuard($repo);
|
||||||
|
$user = new User();
|
||||||
|
$user->setUsername('bob');
|
||||||
|
|
||||||
|
// Aucune exception ne doit etre levee
|
||||||
|
$guard->ensureAtLeastOneAdminRemainsAfterDeletion($user);
|
||||||
|
$this->addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bloque la suppression quand il ne reste exactement qu'un admin.
|
||||||
|
*/
|
||||||
|
public function testBlocksDeletionWhenExactlyOneAdmin(): void
|
||||||
|
{
|
||||||
|
$repo = $this->createMock(UserRepositoryInterface::class);
|
||||||
|
$repo->method('countAdmins')->willReturn(1);
|
||||||
|
|
||||||
|
$guard = new AdminHeadcountGuard($repo);
|
||||||
|
$user = new User();
|
||||||
|
$user->setUsername('bob');
|
||||||
|
|
||||||
|
$this->expectException(LastAdminProtectionException::class);
|
||||||
|
$guard->ensureAtLeastOneAdminRemainsAfterDeletion($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bloque la suppression de facon defensive si le compteur est a 0 (etat incoherent).
|
||||||
|
*/
|
||||||
|
public function testBlocksDeletionDefensivelyWhenZeroAdmin(): void
|
||||||
|
{
|
||||||
|
$repo = $this->createMock(UserRepositoryInterface::class);
|
||||||
|
$repo->method('countAdmins')->willReturn(0);
|
||||||
|
|
||||||
|
$guard = new AdminHeadcountGuard($repo);
|
||||||
|
$user = new User();
|
||||||
|
$user->setUsername('bob');
|
||||||
|
|
||||||
|
$this->expectException(LastAdminProtectionException::class);
|
||||||
|
$guard->ensureAtLeastOneAdminRemainsAfterDeletion($user);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user