feat(core) : RBAC #345 - UserProcessor DELETE guard

Introduit AdminHeadcountGuardInterface pour permettre le mock en tests
unitaires, puis cree UserProcessor qui protege DELETE /api/users/{id}
contre la suppression du dernier administrateur via la garde domaine.
This commit is contained in:
Matthieu
2026-04-15 15:57:19 +02:00
parent ab2f11d40d
commit ba5eb804f2
4 changed files with 224 additions and 1 deletions

View File

@@ -17,7 +17,7 @@ use App\Module\Core\Domain\Repository\UserRepositoryInterface;
* Il compte les admins restants et leve LastAdminProtectionException si
* le seuil minimum (1) serait franchi.
*/
final class AdminHeadcountGuard
final class AdminHeadcountGuard implements AdminHeadcountGuardInterface
{
public function __construct(private readonly UserRepositoryInterface $userRepository) {}

View File

@@ -0,0 +1,31 @@
<?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;
/**
* Contrat du gardien de l'invariant "au moins un admin sur l'instance".
*
* Separer l'interface de l'implementation permet de tester unitairement
* les processors qui dependent de ce garde sans instancier le repository.
*/
interface AdminHeadcountGuardInterface
{
/**
* Verifie qu'il restera au moins un admin apres la demote de $user.
*
* @throws LastAdminProtectionException si le seuil minimum serait franchi
*/
public function ensureAtLeastOneAdminRemainsAfterDemotion(User $user): void;
/**
* Verifie qu'il restera au moins un admin apres la suppression de $user.
*
* @throws LastAdminProtectionException si le seuil minimum serait franchi
*/
public function ensureAtLeastOneAdminRemainsAfterDeletion(User $user): void;
}

View File

@@ -0,0 +1,62 @@
<?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 App\Module\Core\Domain\Exception\LastAdminProtectionException;
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
use LogicException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Processor dedie a l'operation `DELETE /api/users/{id}`.
*
* Delegue la suppression au RemoveProcessor Doctrine decore apres avoir
* applique la garde "dernier admin global" : si l'utilisateur cible est
* le seul admin restant sur l'instance, la suppression est refusee pour
* preserver l'invariant "au moins un administrateur reste toujours".
*
* La garde est portee par AdminHeadcountGuard (domaine), partagee avec
* UserRbacProcessor qui gere le meme invariant sur le chemin PATCH /rbac.
*
* @implements ProcessorInterface<User, User>
*/
final class UserProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly AdminHeadcountGuardInterface $adminHeadcountGuard,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof User) {
// Ce processor est wire exclusivement sur l'operation Delete de User.
// Si on arrive ici avec un autre type, c'est une misconfiguration.
throw new LogicException(sprintf(
'UserProcessor attend une instance de %s, %s recu.',
User::class,
get_debug_type($data),
));
}
// Garde dernier admin global : on ne verifie que si on supprime
// effectivement un admin. La suppression d'un user standard n'a
// aucun impact sur le compteur d'administrateurs.
if ($data->isAdmin()) {
try {
$this->adminHeadcountGuard->ensureAtLeastOneAdminRemainsAfterDeletion($data);
} catch (LastAdminProtectionException $exception) {
throw new BadRequestHttpException($exception->getMessage(), $exception);
}
}
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
}

View File

@@ -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());
}
}