From 48c67a5fb95a4d58eaaab11ca15b5e574a472a35 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 19 Jun 2026 17:16:38 +0200 Subject: [PATCH] feat(core) : expose role and user-rbac api endpoints with processors --- src/Module/Core/Domain/Entity/Permission.php | 2 +- src/Module/Core/Domain/Entity/Role.php | 8 +- src/Module/Core/Domain/Entity/User.php | 17 ++- .../State/Processor/UserRbacProcessor.php | 30 ++++ tests/Functional/Module/Core/RoleApiTest.php | 79 +++++++++++ .../Module/Core/UserRbacApiTest.php | 134 ++++++++++++++++++ 6 files changed, 265 insertions(+), 5 deletions(-) create mode 100644 src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php create mode 100644 tests/Functional/Module/Core/RoleApiTest.php create mode 100644 tests/Functional/Module/Core/UserRbacApiTest.php diff --git a/src/Module/Core/Domain/Entity/Permission.php b/src/Module/Core/Domain/Entity/Permission.php index 5094499..24e4047 100644 --- a/src/Module/Core/Domain/Entity/Permission.php +++ b/src/Module/Core/Domain/Entity/Permission.php @@ -10,7 +10,7 @@ use ApiPlatform\Metadata\GetCollection; use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository; use Doctrine\ORM\Mapping as ORM; use InvalidArgumentException; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; #[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)] #[ORM\Table(name: 'permission')] diff --git a/src/Module/Core/Domain/Entity/Role.php b/src/Module/Core/Domain/Entity/Role.php index 8f15d01..7954dad 100644 --- a/src/Module/Core/Domain/Entity/Role.php +++ b/src/Module/Core/Domain/Entity/Role.php @@ -17,7 +17,8 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use InvalidArgumentException; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Serializer\Attribute\SerializedName; #[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)] #[ORM\Table(name: '`role`')] @@ -54,7 +55,6 @@ class Role private ?string $description; #[ORM\Column(name: 'is_system', options: ['comment' => 'True for built-in roles that cannot be deleted'])] - #[Groups(['role:read'])] private bool $isSystem; /** @@ -111,6 +111,10 @@ class Role $this->description = $description; } + // PropertyInfo strips the `is` prefix and would expose this field as `system`. + // An explicit SerializedName guarantees the `isSystem` key expected by API clients. + #[Groups(['role:read'])] + #[SerializedName('isSystem')] public function isSystem(): bool { return $this->isSystem; diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index 87794ee..553e34a 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -13,6 +13,7 @@ use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use App\Enum\ContractType; use App\Module\Core\Infrastructure\ApiPlatform\State\MeProvider; +use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor; use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor; use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository; use App\Shared\Domain\Contract\UserInterface as SharedUserInterface; @@ -42,6 +43,18 @@ 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 Delete(security: "is_granted('ROLE_ADMIN')"), + new Get( + uriTemplate: '/users/{id}/rbac', + security: "is_granted('core.users.manage')", + normalizationContext: ['groups' => ['user:rbac:read']], + ), + new Patch( + uriTemplate: '/users/{id}/rbac', + security: "is_granted('core.users.manage')", + normalizationContext: ['groups' => ['user:rbac:read']], + denormalizationContext: ['groups' => ['user:rbac:write']], + processor: UserRbacProcessor::class, + ), ], denormalizationContext: ['groups' => ['user:write']], )] @@ -142,7 +155,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU */ #[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')] #[ORM\JoinTable(name: 'user_role')] - #[Groups(['user:rbac:read'])] + #[Groups(['user:rbac:read', 'user:rbac:write'])] private Collection $rbacRoles; /** @@ -150,7 +163,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU */ #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] #[ORM\JoinTable(name: 'user_permission')] - #[Groups(['user:rbac:read'])] + #[Groups(['user:rbac:read', 'user:rbac:write'])] private Collection $directPermissions; public function __construct() diff --git a/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php b/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php new file mode 100644 index 0000000..4658423 --- /dev/null +++ b/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php @@ -0,0 +1,30 @@ + + */ +final readonly class UserRbacProcessor implements ProcessorInterface +{ + public function __construct(private EntityManagerInterface $em) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): User + { + assert($data instanceof User); + + $this->em->persist($data); + $this->em->flush(); + + return $data; + } +} diff --git a/tests/Functional/Module/Core/RoleApiTest.php b/tests/Functional/Module/Core/RoleApiTest.php new file mode 100644 index 0000000..8fcce1d --- /dev/null +++ b/tests/Functional/Module/Core/RoleApiTest.php @@ -0,0 +1,79 @@ +request('GET', '/api/roles'); + + self::assertResponseStatusCodeSame(401); + } + + public function testAdminCanListRoles(): void + { + $client = self::createClient(); + $this->loginAdmin($client); + + $client->request('GET', '/api/roles'); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertArrayHasKey('member', $data); + } + + public function testAdminCanCreateRole(): void + { + $client = self::createClient(); + $this->loginAdmin($client); + + $code = 'bureau_'.uniqid(); + $client->request('POST', '/api/roles', server: [ + 'CONTENT_TYPE' => 'application/ld+json', + ], content: json_encode(['code' => $code, 'label' => 'Bureau'])); + + self::assertResponseStatusCodeSame(201); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertSame($code, $data['code']); + self::assertSame('Bureau', $data['label']); + self::assertFalse($data['isSystem']); + } + + public function testDeletingSystemRoleIsForbidden(): void + { + $client = self::createClient(); + $em = self::getContainer()->get(EntityManagerInterface::class); + + $systemRole = new Role('sys_'.uniqid(), 'System role', 'Rôle système', true); + $em->persist($systemRole); + $em->flush(); + $id = $systemRole->getId(); + + $this->loginAdmin($client); + + $client->request('DELETE', '/api/roles/'.$id); + + self::assertResponseStatusCodeSame(403); + } + + private function loginAdmin(KernelBrowser $client): void + { + $em = self::getContainer()->get(EntityManagerInterface::class); + $user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + self::assertInstanceOf(User::class, $user); + $client->loginUser($user); + } +} diff --git a/tests/Functional/Module/Core/UserRbacApiTest.php b/tests/Functional/Module/Core/UserRbacApiTest.php new file mode 100644 index 0000000..f5623cf --- /dev/null +++ b/tests/Functional/Module/Core/UserRbacApiTest.php @@ -0,0 +1,134 @@ +loginAdmin($client); + $aliceId = $this->userId('alice'); + + $client->request('GET', '/api/users/'.$aliceId.'/rbac'); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertArrayHasKey('rbacRoles', $data); + self::assertArrayHasKey('directPermissions', $data); + self::assertArrayHasKey('effectivePermissions', $data); + } + + public function testRbacRequiresAuthentication(): void + { + $client = self::createClient(); + $aliceId = $this->userId('alice'); + + $client->request('GET', '/api/users/'.$aliceId.'/rbac'); + + self::assertResponseStatusCodeSame(401); + } + + public function testAdminCanAssignRoleViaPatch(): void + { + $client = self::createClient(); + $em = self::getContainer()->get(EntityManagerInterface::class); + + $roleCode = 'reviewer_'.uniqid(); + $role = new Role($roleCode, 'Reviewer'); + $em->persist($role); + + $target = $this->createUser($em, 'rbac-assign-'.uniqid()); + $em->flush(); + $roleId = $role->getId(); + $targetId = $target->getId(); + + $this->loginAdmin($client); + + $client->request('PATCH', '/api/users/'.$targetId.'/rbac', server: [ + 'CONTENT_TYPE' => 'application/merge-patch+json', + ], content: json_encode(['rbacRoles' => ['/api/roles/'.$roleId]])); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertCount(1, $data['rbacRoles']); + + $em->clear(); + $reloaded = $em->getRepository(User::class)->find($targetId); + self::assertCount(1, $reloaded->getRbacRoles()); + self::assertSame($roleCode, $reloaded->getRbacRoles()->first()->getCode()); + } + + public function testPartialPatchDoesNotWipeOtherCollection(): void + { + $client = self::createClient(); + $em = self::getContainer()->get(EntityManagerInterface::class); + + $permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'core.users.view']); + self::assertInstanceOf(Permission::class, $permission); + + $role = new Role('auditor_'.uniqid(), 'Auditor'); + $em->persist($role); + + // Dedicated user pre-loaded with a direct permission. + $target = $this->createUser($em, 'rbac-partial-'.uniqid()); + $target->addDirectPermission($permission); + $em->flush(); + $roleId = $role->getId(); + $targetId = $target->getId(); + + $this->loginAdmin($client); + + // PATCH only rbacRoles — directPermissions is absent from the payload. + $client->request('PATCH', '/api/users/'.$targetId.'/rbac', server: [ + 'CONTENT_TYPE' => 'application/merge-patch+json', + ], content: json_encode(['rbacRoles' => ['/api/roles/'.$roleId]])); + + self::assertResponseIsSuccessful(); + + $em->clear(); + $reloaded = $em->getRepository(User::class)->find($targetId); + self::assertCount(1, $reloaded->getRbacRoles(), 'Role should have been assigned'); + self::assertCount(1, $reloaded->getDirectPermissions(), 'Direct permission must not be wiped by a partial PATCH'); + } + + private function createUser(EntityManagerInterface $em, string $username): User + { + $user = new User(); + $user->setUsername($username); + $user->setPassword('x'); + $user->setRoles(['ROLE_USER']); + $em->persist($user); + + return $user; + } + + private function loginAdmin(KernelBrowser $client): void + { + $em = self::getContainer()->get(EntityManagerInterface::class); + $user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + self::assertInstanceOf(User::class, $user); + $client->loginUser($user); + } + + private function userId(string $username): int + { + $em = self::getContainer()->get(EntityManagerInterface::class); + $user = $em->getRepository(User::class)->findOneBy(['username' => $username]); + self::assertInstanceOf(User::class, $user); + + return $user->getId(); + } +}