feat(core) : RBAC #344 - UserRbacProcessor + endpoint /users/{id}/rbac
Ajoute une operation Patch dediee `PATCH /api/users/{id}/rbac` (nom
`user_rbac_patch`) qui accepte exclusivement les champs RBAC isAdmin,
roles et directPermissions via le groupe user:rbac:write. L'endpoint est
separe volontairement du Patch profil existant pour isoler la modification
des droits de celle des donnees profil (decision 0fc4e16).
UserRbacProcessor delegue au PersistProcessor Doctrine decore et applique
une garde auto-suicide : un admin ne peut pas retirer ses propres droits
administrateur (compare l'etat entrant a l'etat UnitOfWork). La garde
'dernier admin' globale est reportee au ticket #345.
La propriete Doctrine $roles est renommee $rbacRoles pour eviter la
collision avec UserInterface::getRoles() (qui renvoie list<string>) lors
de la normalization API Platform. La cle JSON reste `roles` grace a
SerializedName, le contrat API est inchange.
Tests : 6 unitaires (UserRbacProcessorTest) + 8 fonctionnels
(UserRbacApiTest) couvrant promotion admin, remplacement des collections
roles/directPermissions, 401/403, filtrage du groupe denormalization
(`username` ignore), preservation de isAdmin sur le Patch profil, et
garde auto-suicide.
This commit is contained in:
@@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
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\Doctrine\DoctrineUserRepository;
|
||||
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\UserInterface;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
@@ -36,6 +38,15 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
),
|
||||
new Post(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')"),
|
||||
],
|
||||
denormalizationContext: ['groups' => ['user:write']],
|
||||
@@ -55,7 +66,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
private ?string $username = null;
|
||||
|
||||
#[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;
|
||||
|
||||
/**
|
||||
@@ -70,20 +81,25 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')]
|
||||
#[ORM\JoinTable(name: 'user_role')]
|
||||
#[Groups(['me:read', 'user:list'])]
|
||||
private Collection $roles;
|
||||
#[Groups(['me:read', 'user:list', 'user:rbac:write'])]
|
||||
// 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.
|
||||
*
|
||||
* Meme justification EAGER que pour $roles : garantie que
|
||||
* Meme justification EAGER que pour $rbacRoles : garantie que
|
||||
* getEffectivePermissions() fonctionne dans tous les contextes de chargement.
|
||||
*
|
||||
* @var Collection<int, Permission>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
|
||||
#[ORM\JoinTable(name: 'user_permission')]
|
||||
#[Groups(['me:read', 'user:list'])]
|
||||
#[Groups(['me:read', 'user:list', 'user:rbac:write'])]
|
||||
private Collection $directPermissions;
|
||||
|
||||
#[ORM\Column]
|
||||
@@ -98,7 +114,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->roles = new ArrayCollection();
|
||||
$this->rbacRoles = 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
|
||||
* SEUL levier technique de bypass RBAC (cf. section 11 du spec).
|
||||
*
|
||||
* Important : ne JAMAIS iterer $this->roles (la Collection de Role) ici.
|
||||
* Cette methode peut etre appelee pendant un refresh JWT, moment ou la
|
||||
* Collection peut ne pas etre hydratee. On se contente d'un calcul base
|
||||
* sur un scalaire.
|
||||
* Important : ne JAMAIS iterer $this->rbacRoles (la Collection de Role)
|
||||
* ici. Cette methode peut etre appelee pendant un refresh JWT, moment ou
|
||||
* la Collection peut ne pas etre hydratee. On se contente d'un calcul
|
||||
* base sur un scalaire.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
@@ -170,13 +186,13 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
*/
|
||||
public function getRbacRoles(): Collection
|
||||
{
|
||||
return $this->roles;
|
||||
return $this->rbacRoles;
|
||||
}
|
||||
|
||||
public function addRbacRole(Role $role): static
|
||||
{
|
||||
if (!$this->roles->contains($role)) {
|
||||
$this->roles->add($role);
|
||||
if (!$this->rbacRoles->contains($role)) {
|
||||
$this->rbacRoles->add($role);
|
||||
}
|
||||
|
||||
return $this;
|
||||
@@ -184,7 +200,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
|
||||
public function removeRbacRole(Role $role): static
|
||||
{
|
||||
$this->roles->removeElement($role);
|
||||
$this->rbacRoles->removeElement($role);
|
||||
|
||||
return $this;
|
||||
}
|
||||
@@ -229,7 +245,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
{
|
||||
$codes = [];
|
||||
|
||||
foreach ($this->roles as $role) {
|
||||
foreach ($this->rbacRoles as $role) {
|
||||
foreach ($role->getPermissions() as $permission) {
|
||||
$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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user