RBAC - Système complet de permissions (Backend + Frontend) (#7)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
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>
This commit was merged in pull request #7.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user