feat(core) : RBAC #344 - RoleProcessor + gardes systeme et code immuable

This commit is contained in:
Matthieu
2026-04-15 11:58:37 +02:00
parent efc12c8bdb
commit d527fbe2d1
4 changed files with 378 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Core\Domain\Exception\SystemRoleDeletionException;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor;
use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -47,16 +48,19 @@ use Symfony\Component\Validator\Constraints as Assert;
denormalizationContext: ['groups' => ['role:write']],
// TODO ticket #345 : remplacer par is_granted('core.roles.manage')
security: "is_granted('ROLE_ADMIN')",
processor: RoleProcessor::class,
),
new Patch(
normalizationContext: ['groups' => ['role:read']],
denormalizationContext: ['groups' => ['role:write']],
// TODO ticket #345 : remplacer par is_granted('core.roles.manage')
security: "is_granted('ROLE_ADMIN')",
processor: RoleProcessor::class,
),
new Delete(
// TODO ticket #345 : remplacer par is_granted('core.roles.manage')
security: "is_granted('ROLE_ADMIN')",
processor: RoleProcessor::class,
),
],
normalizationContext: ['groups' => ['role:read']],
@@ -159,6 +163,19 @@ class Role
return $this->permissions;
}
/**
* Setter expose uniquement a la denormalisation API Platform pour
* permettre au RoleProcessor de detecter une tentative de modification
* du code (garde "code immuable"). Le code reste en pratique fige apres
* creation : le processor refuse toute modification via 400.
*/
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
/**
* Met a jour le libelle affichable du role. Le code reste immuable pour
* garantir la stabilite des references cote fixtures et migrations.

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Exception\SystemRoleDeletionException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Processor applicatif pour l'entite Role.
*
* Choix d'implementation : une seule classe qui recoit en dependances les deux
* processors Doctrine decores (Persist et Remove) et branche l'un ou l'autre
* selon le type d'operation. Ce choix reste plus lisible que deux classes
* jumelees et reflete la symetrie des gardes metier (immuabilite du `code`
* cote ecriture, protection des roles systeme cote suppression).
*
* Gardes metier :
* - DELETE : delegue a Role::ensureDeletable() et traduit la
* SystemRoleDeletionException en AccessDeniedHttpException (403).
* - POST/PATCH : refuse toute modification du `code` (champ immuable apres
* creation), regle uniforme pour les roles systeme ET custom.
*
* @implements ProcessorInterface<Role, null|Role>
*/
final class RoleProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly EntityManagerInterface $entityManager,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Role) {
// Securite : si le provider n'a pas fourni un Role, on delegue
// quand meme au processor approprie pour ne pas etouffer
// silencieusement un bug de configuration.
return $operation instanceof DeleteOperationInterface
? $this->removeProcessor->process($data, $operation, $uriVariables, $context)
: $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
try {
$data->ensureDeletable();
} catch (SystemRoleDeletionException $e) {
// Traduction HTTP : le domaine reste pur, l'API renvoie 403.
throw new AccessDeniedHttpException($e->getMessage(), $e);
}
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
// Ecriture (POST/PATCH) : verifier l'immuabilite du `code`.
// L'UnitOfWork n'expose un etat d'origine que pour les entites deja
// managees (PATCH). Pour un POST (entite nouvelle), `getOriginalEntityData`
// retourne un tableau vide : aucune comparaison necessaire.
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data);
if (isset($originalData['code']) && $originalData['code'] !== $data->getCode()) {
throw new BadRequestHttpException("Le code d'un role est immuable apres creation.");
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
}