Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
## Résumé
Implémentation complète du système RBAC (Role-Based Access Control) pour Coltura.
### Backend
- Entités Permission et Role avec API Platform CRUD
- PermissionVoter : vérification des permissions effectives (rôles + directes), admin bypass
- Endpoints `PATCH /users/{id}/rbac` pour assigner rôles, permissions directes et isAdmin
- AdminHeadcountGuard : protection contre la suppression du dernier admin
- Commande `app:sync-permissions` pour synchroniser les permissions déclarées par les modules
- Filtrage sidebar par permission RBAC (`permission` key optionnelle dans sidebar.php)
- 115 tests PHPUnit (fonctionnels + unitaires)
### Frontend
- Composable `usePermissions()` avec `can()`, `canAny()`, `canAll()` et admin bypass
- Page `/admin/roles` : DataTable, création/édition via drawer, suppression avec confirmation
- Page `/admin/users` : DataTable, drawer RBAC avec rôles, permissions directes, résumé effectif
- PermissionGroup : checkboxes groupées par module avec "tout sélectionner"
- EffectivePermissions : résumé lecture seule avec badges source ("via Rôle X" / "Direct")
- Warning auto-édition, toggle isAdmin
- Tests Vitest pour usePermissions
### Permissions déclarées
- `core.users.view` — Voir les utilisateurs
- `core.users.manage` — Gérer les utilisateurs
- `core.roles.view` — Voir les rôles RBAC
- `core.roles.manage` — Gérer les rôles et permissions
- `GET /api/permissions` accessible à tout utilisateur authentifié (catalogue read-only)
## Tickets Lesstime
- ERP-23 (#343) — Entités Permission et Role
- ERP-24 (#344) — API CRUD Roles & Permissions
- ERP-25 (#345) — Voter Symfony + usePermissions
- ERP-26 (#346) — Interface Admin : Gestion des Rôles
- ERP-27 (#347) — Interface Admin : Permissions Utilisateur
## Test plan
- [ ] `make db-reset` puis vérifier les fixtures (admin/alice/bob, rôles système)
- [ ] Login admin : sidebar affiche Gestion des rôles + Utilisateurs
- [ ] Login alice : sidebar masque ces onglets (pas de permission)
- [ ] Page /admin/roles : CRUD rôles, permissions groupées, protection rôles système
- [ ] Page /admin/users : assignation rôles + permissions directes, résumé effectif
- [ ] Warning auto-édition quand admin modifie ses propres droits
- [ ] `make test` : 115 tests PHPUnit passent
- [ ] `cd frontend && npm run test` : tests Vitest passent
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Matthieu <mtholot19@gmail.com>
Co-authored-by: tristan <tristan@yuno.malio.fr>
Reviewed-on: #7
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
306 lines
12 KiB
PHP
306 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Module\Core\Api;
|
|
|
|
use App\Module\Core\Domain\Entity\Permission;
|
|
use App\Module\Core\Domain\Entity\Role;
|
|
use App\Module\Core\Domain\Entity\User;
|
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
|
|
|
/**
|
|
* Tests fonctionnels de l'endpoint RBAC dedie `PATCH /api/users/{id}/rbac`.
|
|
*
|
|
* Strategie de donnees :
|
|
* - On cree des users, roles et permissions prefixes `test_` / `test.`
|
|
* en setUp et on les purge en tearDown.
|
|
* - On ne touche JAMAIS aux fixtures (admin / alice / bob). Les cas qui
|
|
* ont besoin d'un user standard authentifie s'appuient sur alice sans
|
|
* modification d'etat.
|
|
* - Les users de test incluent un admin dedie pour le cas d'auto-suicide,
|
|
* pour ne pas risquer de corrompre l'admin fixture.
|
|
*
|
|
* @internal
|
|
*/
|
|
final class UserRbacApiTest extends AbstractApiTestCase
|
|
{
|
|
private const TEST_USER_PREFIX = 'test_';
|
|
private const TEST_ROLE_PREFIX = 'test_';
|
|
private const TEST_PERMISSION_PREFIX = 'test.';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
self::bootKernel();
|
|
$em = $this->getEm();
|
|
|
|
$this->cleanupTestData();
|
|
|
|
/** @var UserPasswordHasherInterface $hasher */
|
|
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
|
|
|
// User cible standard (non admin).
|
|
$target = new User();
|
|
$target->setUsername('test_target');
|
|
$target->setIsAdmin(false);
|
|
$target->setPassword($hasher->hashPassword($target, 'secret'));
|
|
$em->persist($target);
|
|
|
|
// User admin dedie pour le cas d'auto-suicide (pas l'admin fixture).
|
|
$selfAdmin = new User();
|
|
$selfAdmin->setUsername('test_self_admin');
|
|
$selfAdmin->setIsAdmin(true);
|
|
$selfAdmin->setPassword($hasher->hashPassword($selfAdmin, 'secret'));
|
|
$em->persist($selfAdmin);
|
|
|
|
// Role custom pour tester le remplacement de la collection roles.
|
|
$role = new Role('test_editor', 'Editeur (test)', false);
|
|
$em->persist($role);
|
|
|
|
// Permission custom pour tester directPermissions.
|
|
$permission = new Permission('test.core.users.view', 'View users (test)', 'core');
|
|
$em->persist($permission);
|
|
|
|
$em->flush();
|
|
$em->clear();
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
$this->cleanupTestData();
|
|
parent::tearDown();
|
|
}
|
|
|
|
public function testPatchRbacPromotesUserToAdmin(): void
|
|
{
|
|
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
|
self::assertNotNull($target);
|
|
|
|
$client = $this->authenticatedClient('admin', 'admin');
|
|
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
|
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
|
'json' => ['isAdmin' => true],
|
|
]);
|
|
|
|
self::assertResponseIsSuccessful();
|
|
|
|
$em = $this->getEm();
|
|
$em->clear();
|
|
|
|
/** @var User $reloaded */
|
|
$reloaded = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
|
self::assertTrue($reloaded->isAdmin());
|
|
}
|
|
|
|
public function testPatchRbacReplacesRolesCollection(): void
|
|
{
|
|
$em = $this->getEm();
|
|
$target = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
|
$role = $em->getRepository(Role::class)->findOneBy(['code' => 'test_editor']);
|
|
self::assertNotNull($target);
|
|
self::assertNotNull($role);
|
|
|
|
$client = $this->authenticatedClient('admin', 'admin');
|
|
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
|
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
|
'json' => ['roles' => ['/api/roles/'.$role->getId()]],
|
|
]);
|
|
|
|
self::assertResponseIsSuccessful();
|
|
|
|
$em = $this->getEm();
|
|
$em->clear();
|
|
|
|
/** @var User $reloaded */
|
|
$reloaded = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
|
self::assertCount(1, $reloaded->getRbacRoles());
|
|
self::assertSame('test_editor', $reloaded->getRbacRoles()->first()->getCode());
|
|
}
|
|
|
|
public function testPatchRbacReplacesDirectPermissionsCollection(): void
|
|
{
|
|
$em = $this->getEm();
|
|
$target = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
|
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'test.core.users.view']);
|
|
self::assertNotNull($target);
|
|
self::assertNotNull($permission);
|
|
|
|
$client = $this->authenticatedClient('admin', 'admin');
|
|
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
|
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
|
'json' => ['directPermissions' => ['/api/permissions/'.$permission->getId()]],
|
|
]);
|
|
|
|
self::assertResponseIsSuccessful();
|
|
|
|
$em = $this->getEm();
|
|
$em->clear();
|
|
|
|
/** @var User $reloaded */
|
|
$reloaded = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
|
self::assertCount(1, $reloaded->getDirectPermissions());
|
|
self::assertSame('test.core.users.view', $reloaded->getDirectPermissions()->first()->getCode());
|
|
}
|
|
|
|
public function testPatchRbacAsStandardUserReturns403(): void
|
|
{
|
|
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
|
self::assertNotNull($target);
|
|
|
|
$client = $this->authenticatedClient('alice', 'alice');
|
|
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
|
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
|
'json' => ['isAdmin' => true],
|
|
]);
|
|
|
|
self::assertResponseStatusCodeSame(403);
|
|
}
|
|
|
|
public function testPatchRbacUnauthenticatedReturns401(): void
|
|
{
|
|
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
|
self::assertNotNull($target);
|
|
|
|
$client = self::createClient();
|
|
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
|
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
|
'json' => ['isAdmin' => true],
|
|
]);
|
|
|
|
self::assertResponseStatusCodeSame(401);
|
|
}
|
|
|
|
public function testPatchRbacIgnoresUsernameField(): void
|
|
{
|
|
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
|
self::assertNotNull($target);
|
|
$targetId = $target->getId();
|
|
|
|
$client = $this->authenticatedClient('admin', 'admin');
|
|
$client->request('PATCH', '/api/users/'.$targetId.'/rbac', [
|
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
|
'json' => [
|
|
'username' => 'test_target_renamed',
|
|
'isAdmin' => true,
|
|
],
|
|
]);
|
|
|
|
self::assertResponseIsSuccessful();
|
|
|
|
$em = $this->getEm();
|
|
$em->clear();
|
|
|
|
/** @var User $reloaded */
|
|
$reloaded = $em->getRepository(User::class)->find($targetId);
|
|
// `username` n'est pas dans `user:rbac:write` : ignore en denormalization.
|
|
self::assertSame('test_target', $reloaded->getUsername());
|
|
// `isAdmin` est bien applique.
|
|
self::assertTrue($reloaded->isAdmin());
|
|
}
|
|
|
|
public function testPatchProfileEndpointDoesNotModifyIsAdmin(): void
|
|
{
|
|
// Confirme la decision 0fc4e16 : `isAdmin` n'est plus dans `user:write`,
|
|
// donc `PATCH /api/users/{id}` sans `/rbac` ne peut plus promouvoir.
|
|
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
|
self::assertNotNull($target);
|
|
$targetId = $target->getId();
|
|
self::assertFalse($target->isAdmin());
|
|
|
|
$client = $this->authenticatedClient('admin', 'admin');
|
|
$client->request('PATCH', '/api/users/'.$targetId, [
|
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
|
'json' => ['isAdmin' => true],
|
|
]);
|
|
|
|
// Peu importe le code : le champ ne doit tout simplement pas bouger.
|
|
$em = $this->getEm();
|
|
$em->clear();
|
|
|
|
/** @var User $reloaded */
|
|
$reloaded = $em->getRepository(User::class)->find($targetId);
|
|
self::assertFalse($reloaded->isAdmin());
|
|
}
|
|
|
|
// --- Tests voter RBAC : non-admin avec / sans permission ---
|
|
|
|
public function testPatchRbacAsUserWithManagePermissionReturns200(): void
|
|
{
|
|
// Un non-admin portant core.users.manage doit pouvoir appeler PATCH /rbac.
|
|
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
|
self::assertNotNull($target);
|
|
|
|
$credentials = $this->createUserWithPermission('core.users.manage');
|
|
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
|
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
|
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
|
'json' => ['isAdmin' => false],
|
|
]);
|
|
|
|
self::assertResponseIsSuccessful();
|
|
}
|
|
|
|
public function testPatchRbacAsUserWithOnlyViewPermissionReturns403(): void
|
|
{
|
|
// Un user avec core.users.view uniquement ne peut pas ecrire via /rbac.
|
|
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
|
self::assertNotNull($target);
|
|
|
|
$credentials = $this->createUserWithPermission('core.users.view');
|
|
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
|
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
|
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
|
'json' => ['isAdmin' => true],
|
|
]);
|
|
|
|
self::assertResponseStatusCodeSame(403);
|
|
}
|
|
|
|
public function testPatchRbacSelfRemovingAdminReturns400(): void
|
|
{
|
|
// On utilise le user admin dedie (test_self_admin) pour ne pas
|
|
// corrompre l'admin fixture en cas de bug.
|
|
$em = $this->getEm();
|
|
$selfAdmin = $em->getRepository(User::class)->findOneBy(['username' => 'test_self_admin']);
|
|
self::assertNotNull($selfAdmin);
|
|
$selfAdminId = $selfAdmin->getId();
|
|
|
|
$client = $this->authenticatedClient('test_self_admin', 'secret');
|
|
$client->request('PATCH', '/api/users/'.$selfAdminId.'/rbac', [
|
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
|
'json' => ['isAdmin' => false],
|
|
]);
|
|
|
|
self::assertResponseStatusCodeSame(400);
|
|
|
|
$em = $this->getEm();
|
|
$em->clear();
|
|
|
|
/** @var User $reloaded */
|
|
$reloaded = $em->getRepository(User::class)->find($selfAdminId);
|
|
self::assertTrue($reloaded->isAdmin());
|
|
}
|
|
|
|
private function cleanupTestData(): void
|
|
{
|
|
$em = $this->getEm();
|
|
|
|
// Ordre important : delier les collections avant de supprimer les
|
|
// entites referencees pour que les FK cascade s'appliquent via le
|
|
// schema PostgreSQL.
|
|
$em->createQuery(
|
|
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix'
|
|
)->setParameter('prefix', self::TEST_USER_PREFIX.'%')->execute();
|
|
|
|
$em->createQuery(
|
|
'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix'
|
|
)->setParameter('prefix', self::TEST_ROLE_PREFIX.'%')->execute();
|
|
|
|
$em->createQuery(
|
|
'DELETE FROM '.Permission::class.' p WHERE p.code LIKE :prefix'
|
|
)->setParameter('prefix', self::TEST_PERMISSION_PREFIX.'%')->execute();
|
|
}
|
|
}
|