RBAC - Système complet de permissions (Backend + Frontend) (#7)
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: MALIO-DEV/Coltura#7
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #7.
This commit is contained in:
2026-04-17 12:34:38 +00:00
committed by Autin
parent b59d0f8a44
commit e8c2789435
65 changed files with 9985 additions and 386 deletions

View File

@@ -0,0 +1,81 @@
<?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 LogicException;
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) {
// Ce processor est wire exclusivement sur les operations Role.
// Si on arrive ici avec autre chose, c'est une misconfiguration
// qu'il faut faire remonter fort.
throw new LogicException(sprintf(
'RoleProcessor attend une instance de %s, %s recu.',
Role::class,
get_debug_type($data),
));
}
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);
}
}

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,85 @@
<?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 Doctrine\ORM\EntityManagerInterface;
use LogicException;
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 (dans l'ordre d'execution) :
* - Auto-suicide : un admin ne peut pas retirer son propre flag `isAdmin`.
* Cas particulier plus strict, avec message dedie.
* - Dernier admin global : impossible de retirer `isAdmin` si c'est le
* dernier administrateur de l'instance, meme par un tiers. Enforce via
* AdminHeadcountGuardInterface.
*
* @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,
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 user_rbac_patch
// qui cible User. Si on arrive ici avec autre chose, c'est une
// misconfiguration qu'il faut faire remonter fort.
throw new LogicException(sprintf(
'UserRbacProcessor attend une instance de %s, %s recu.',
User::class,
get_debug_type($data),
));
}
$currentUser = $this->security->getUser();
// Calcul partage entre les deux gardes : l'user perdait-il le flag admin ?
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data);
$wasAdmin = $originalData['isAdmin'] ?? null;
$willLoseAdmin = true === $wasAdmin && false === $data->isAdmin();
// Garde auto-suicide : cas particulier plus strict — l'user courant ne
// peut pas retirer son propre flag admin, meme si d'autres admins existent.
if ($willLoseAdmin && $currentUser instanceof User && $currentUser->getId() === $data->getId()) {
throw new BadRequestHttpException(
'Vous ne pouvez pas retirer vos propres droits administrateur.'
);
}
// Garde dernier admin global : invariant general — impossible de retirer
// isAdmin si cela laisserait l'instance sans administrateur.
if ($willLoseAdmin) {
try {
$this->adminHeadcountGuard->ensureAtLeastOneAdminRemainsAfterDemotion($data);
} catch (LastAdminProtectionException $exception) {
throw new BadRequestHttpException($exception->getMessage(), $exception);
}
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Module\Core\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @implements ProviderInterface<object>
@@ -13,7 +14,7 @@ use ApiPlatform\State\ProviderInterface;
class MeProvider implements ProviderInterface
{
public function __construct(
private readonly \Symfony\Bundle\SecurityBundle\Security $security,
private readonly Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object

View File

@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Console;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@@ -17,13 +19,14 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
#[AsCommand(
name: 'app:create-user',
description: 'Create a new user',
description: 'Cree un utilisateur rattache au role systeme admin ou user.',
)]
class CreateUserCommand extends Command
final class CreateUserCommand extends Command
{
public function __construct(
private readonly UserRepositoryInterface $userRepository,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly RoleRepositoryInterface $roleRepository,
) {
parent::__construct();
}
@@ -31,9 +34,9 @@ class CreateUserCommand extends Command
protected function configure(): void
{
$this
->addArgument('username', InputArgument::REQUIRED, 'Username')
->addArgument('password', InputArgument::REQUIRED, 'Plain password')
->addOption('admin', null, InputOption::VALUE_NONE, 'Grant ROLE_ADMIN')
->addArgument('username', InputArgument::REQUIRED, 'Nom d\'utilisateur')
->addArgument('password', InputArgument::REQUIRED, 'Mot de passe en clair')
->addOption('admin', null, InputOption::VALUE_NONE, 'Rattache au role systeme admin + active is_admin')
;
}
@@ -43,18 +46,34 @@ class CreateUserCommand extends Command
$username = $input->getArgument('username');
$plainPassword = $input->getArgument('password');
$isAdmin = (bool) $input->getOption('admin');
$roleCode = $isAdmin ? SystemRoles::ADMIN_CODE : SystemRoles::USER_CODE;
$role = $this->roleRepository->findByCode($roleCode);
if (null === $role) {
$io->error(sprintf(
'Le role systeme "%s" est introuvable. Lance "bin/console doctrine:migrations:migrate" pour le seeder.',
$roleCode,
));
return Command::FAILURE;
}
$user = new User();
$user->setUsername($username);
$user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword));
if ($input->getOption('admin')) {
$user->setRoles(['ROLE_ADMIN']);
}
$user->setIsAdmin($isAdmin);
$user->addRbacRole($role);
$this->userRepository->save($user);
$io->success(sprintf('User "%s" created%s.', $username, $input->getOption('admin') ? ' with ROLE_ADMIN' : ''));
$io->success(sprintf(
'Utilisateur "%s" cree, rattache au role systeme "%s"%s.',
$username,
$roleCode,
$isAdmin ? ' (bypass is_admin actif)' : '',
));
return Command::SUCCESS;
}

View File

@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Console;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Throwable;
use function count;
#[AsCommand(
name: 'app:sync-permissions',
description: 'Synchronise les permissions RBAC declarees par les modules actifs.',
)]
final class SyncPermissionsCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly PermissionRepositoryInterface $permissionRepository,
#[Autowire(param: 'kernel.project_dir')]
private readonly string $projectDir,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
// Etape 1 : scan + validation stricte des modules actifs AVANT
// tout acces en ecriture a la base, afin qu'une erreur de
// declaration laisse la table `permission` intacte.
$desiredPermissions = $this->collectDesiredPermissions();
} catch (InvalidArgumentException $e) {
$io->error($e->getMessage());
return Command::FAILURE;
}
// Etape 2 : upsert transactionnel non destructif.
$this->em->beginTransaction();
try {
// Indexation des permissions existantes par code pour un acces O(1).
$existingByCode = [];
foreach ($this->permissionRepository->findAll() as $permission) {
$existingByCode[$permission->getCode()] = $permission;
}
$added = 0;
$updated = 0;
$orphans = 0;
// Upsert : chaque entree desiree est creee, revivee ou mise a jour.
foreach ($desiredPermissions as $code => $entry) {
$label = $entry['label'];
$module = $entry['module'];
if (isset($existingByCode[$code])) {
$existing = $existingByCode[$code];
if ($existing->isOrphan()) {
// Revival : le code reapparait dans le source, on
// rafraichit ses metadonnees et on retire le flag.
$existing->revive($label, $module);
++$updated;
} elseif ($existing->getLabel() !== $label || $existing->getModule() !== $module) {
// Mise a jour des metadonnees sans toucher au flag orphan.
$existing->updateMetadata($label, $module);
++$updated;
}
// Sinon : strictement identique, no-op.
} else {
// Creation : on persiste directement via l'EM pour ne
// pas declencher un flush par appel (cf. save() repo).
$permission = new Permission($code, $label, $module);
$this->em->persist($permission);
++$added;
}
}
// Etape 3 : marquage orphelin des permissions absentes du source.
foreach ($existingByCode as $code => $existing) {
if (isset($desiredPermissions[$code])) {
continue;
}
if (!$existing->isOrphan()) {
$existing->markOrphan();
++$orphans;
}
}
// Un unique flush regroupe toutes les mutations de la transaction.
$this->em->flush();
$this->em->commit();
} catch (Throwable $e) {
$this->em->rollback();
$io->error(sprintf('Echec de la synchronisation des permissions : %s', $e->getMessage()));
return Command::FAILURE;
}
$totalInDb = count($this->permissionRepository->findAll());
$io->success('Synchronisation des permissions RBAC terminee.');
$io->table(
['Indicateur', 'Valeur'],
[
['Permissions ajoutees', (string) $added],
['Permissions mises a jour ou revivees', (string) $updated],
['Permissions marquees orphelines', (string) $orphans],
['Total en base apres sync', (string) $totalInDb],
],
);
return Command::SUCCESS;
}
/**
* Parcourt la liste des modules actifs declares dans `config/modules.php`,
* extrait leurs permissions statiques, valide strictement chaque entree
* puis renvoie une map indexee par code.
*
* Regles de validation appliquees :
* - chaque entree doit posseder exactement les cles `code` et `label`
* - le `code` doit etre prefixe par `<ModuleClass>::ID . '.'`
* - `code` et `label` ne peuvent pas etre des chaines vides
*
* Les modules ne definissant pas de methode statique `permissions()` sont
* ignores silencieusement (compat ascendante pour les modules legacy).
*
* @return array<string, array{code: string, label: string, module: string}>
*/
private function collectDesiredPermissions(): array
{
/** @var array<int, class-string> $moduleClasses */
$moduleClasses = require $this->projectDir.'/config/modules.php';
$desired = [];
foreach ($moduleClasses as $moduleClass) {
if (!method_exists($moduleClass, 'permissions')) {
continue;
}
/** @var array<int, array<string, string>> $entries */
$entries = $moduleClass::permissions();
$moduleId = $moduleClass::ID;
foreach ($entries as $entry) {
$keys = array_keys($entry);
sort($keys);
if (['code', 'label'] !== $keys) {
throw new InvalidArgumentException(sprintf(
'Permission malformee declaree par %s : chaque entree doit contenir exactement les cles [code, label], recu [%s].',
$moduleClass,
implode(', ', array_keys($entry)),
));
}
$code = $entry['code'];
$label = $entry['label'];
if ('' === $code) {
throw new InvalidArgumentException(sprintf(
'Permission invalide declaree par %s : le code ne peut pas etre vide.',
$moduleClass,
));
}
if ('' === $label) {
throw new InvalidArgumentException(sprintf(
'Permission invalide declaree par %s (code "%s") : le libelle ne peut pas etre vide.',
$moduleClass,
$code,
));
}
$expectedPrefix = $moduleId.'.';
if (!str_starts_with($code, $expectedPrefix)) {
throw new InvalidArgumentException(sprintf(
'Permission invalide declaree par %s : le code "%s" doit etre prefixe par "%s" (ID du module).',
$moduleClass,
$code,
$expectedPrefix,
));
}
$desired[$code] = [
'code' => $code,
'label' => $label,
'module' => $moduleId,
];
}
}
return $desired;
}
}

View File

@@ -4,37 +4,90 @@ declare(strict_types=1);
namespace App\Module\Core\Infrastructure\DataFixtures;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Fixtures de base du module Core : 3 utilisateurs (1 admin + 2 standards)
* rattaches aux roles systeme RBAC seedes par la migration Version20260414150034.
*
* Note : le purger Doctrine execute avant load() supprime l'ensemble des
* entites managees, ce qui inclut la table role. On re-seede donc les roles
* systeme de maniere idempotente avant de rattacher les utilisateurs, afin
* que le workflow "make db-reset && make fixtures" reste one-shot.
*/
class AppFixtures extends Fixture
{
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly RoleRepositoryInterface $roleRepository,
) {}
public function load(ObjectManager $manager): void
{
$adminRole = $this->ensureSystemRole(
$manager,
SystemRoles::ADMIN_CODE,
'Administrateur',
'Role administrateur - bypass complet via is_admin',
);
$userRole = $this->ensureSystemRole(
$manager,
SystemRoles::USER_CODE,
'Utilisateur',
'Role de base sans permission specifique',
);
$admin = new User();
$admin->setUsername('admin');
$admin->setRoles(['ROLE_ADMIN']);
$admin->setIsAdmin(true);
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
$admin->addRbacRole($adminRole);
$manager->persist($admin);
$alice = new User();
$alice->setUsername('alice');
$alice->setRoles(['ROLE_USER']);
$alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice'));
$alice->addRbacRole($userRole);
$manager->persist($alice);
$bob = new User();
$bob->setUsername('bob');
$bob->setRoles(['ROLE_USER']);
$bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob'));
$bob->addRbacRole($userRole);
$manager->persist($bob);
$manager->flush();
}
/**
* Retourne le role systeme correspondant au code donne, en le creant
* s'il n'existe pas encore (le purger Doctrine a pu vider la table role).
*
* La description est recopiee depuis la migration RBAC pour que les
* deux chemins (migration prod, fixtures dev) produisent un etat
* identique.
*/
private function ensureSystemRole(
ObjectManager $manager,
string $code,
string $label,
string $description,
): Role {
$role = $this->roleRepository->findByCode($code);
if (null !== $role) {
return $role;
}
$role = new Role($code, $label, isSystem: true, description: $description);
$manager->persist($role);
return $role;
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Permission>
*/
class DoctrinePermissionRepository extends ServiceEntityRepository implements PermissionRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Permission::class);
}
public function findById(int $id): ?Permission
{
return $this->find($id);
}
public function findByCode(string $code): ?Permission
{
return $this->findOneBy(['code' => $code]);
}
/**
* @return array<int, Permission>
*/
public function findAll(): array
{
return parent::findAll();
}
/**
* @return array<int, string>
*/
public function findAllCodes(): array
{
// Requete legere : on ne selectionne que la colonne code (pas d'hydratation
// d'entites Permission) car findAllCodes() est appelee par la commande de
// sync et le futur voter qui n'ont besoin que des chaines.
$rows = $this->createQueryBuilder('p')
->select('p.code')
->getQuery()
->getArrayResult()
;
return array_column($rows, 'code');
}
public function save(Permission $permission): void
{
$this->getEntityManager()->persist($permission);
$this->getEntityManager()->flush();
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Role>
*/
class DoctrineRoleRepository extends ServiceEntityRepository implements RoleRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Role::class);
}
public function findById(int $id): ?Role
{
return $this->find($id);
}
public function findByCode(string $code): ?Role
{
return $this->findOneBy(['code' => $code]);
}
/**
* @return array<int, Role>
*/
public function findAll(): array
{
return parent::findAll();
}
public function save(Role $role): void
{
$this->getEntityManager()->persist($role);
$this->getEntityManager()->flush();
}
}

View File

@@ -34,4 +34,20 @@ class DoctrineUserRepository extends ServiceEntityRepository implements UserRepo
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
/**
* Compte les utilisateurs ayant le flag isAdmin a true.
*
* Utilise par AdminHeadcountGuard pour verifier que l'instance conserve
* toujours au moins un administrateur apres une demote ou une suppression.
*/
public function countAdmins(): int
{
return (int) $this->createQueryBuilder('u')
->select('COUNT(u.id)')
->where('u.isAdmin = true')
->getQuery()
->getSingleScalarResult()
;
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Security;
use App\Module\Core\Domain\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Voter RBAC qui evalue les codes de permission metier au format
* "module.resource.action" (ex: "core.users.view").
*
* - Ignore silencieusement les attributs non-RBAC (ROLE_*, IS_AUTHENTICATED_*, ...),
* qui restent traites par les voters core de Symfony. Strategy 'affirmative'
* par defaut : tant qu'un voter repond GRANTED, l'acces est accorde.
* - Bypass total si l'utilisateur porte le flag isAdmin (decision architecturale
* gravee au ticket #343 section 11 : is_admin est le seul levier technique
* de bypass, jamais remplace par un check de role).
* - Sinon, compare l'attribut aux permissions effectives de l'utilisateur
* (union dedupliquee triee venant des roles et des permissions directes).
*
* @extends Voter<string, mixed>
*/
final class PermissionVoter extends Voter
{
/**
* Regex de reconnaissance des codes de permission.
*
* Contraintes :
* - Premier caractere alphabetique minuscule (pas de chiffre, pas de ROLE_).
* - Au moins un point de separation (ecarte les attributs atomiques
* type ROLE_ADMIN ou IS_AUTHENTICATED_FULLY).
* - Segments en snake_case minuscule coherents avec les permissions
* declarees par les *Module::permissions() et validees par app:sync-permissions.
*/
private const string PERMISSION_CODE_PATTERN = '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/';
protected function supports(string $attribute, mixed $subject): bool
{
return (bool) preg_match(self::PERMISSION_CODE_PATTERN, $attribute);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
// Token anonyme ou user d'un autre type : on refuse explicitement.
// Les voters core (AuthenticatedVoter) se chargent deja du cas
// "pas authentifie du tout".
return false;
}
if ($user->isAdmin()) {
// Bypass total : decision architecturale #343 section 11.
// Cette regle est dupliquee cote front dans usePermissions()
// et les deux doivent bouger ensemble si elle evolue un jour.
return true;
}
return in_array($attribute, $user->getEffectivePermissions(), true);
}
}