feat(core) : RBAC Task 4 - CoreModule::permissions() + SyncPermissionsCommand

- CoreModule declare 4 permissions initiales (users.view/manage, roles.manage,
  permissions.view)
- Nouvelle commande app:sync-permissions :
  * scan des *Module::permissions() via config/modules.php
  * validation stricte : cles [code, label], prefixe module, non-vides
  * upsert transactionnel non-destructif
  * revival des permissions orphelines qui reapparaissent
  * marquage orphan pour les permissions disparues du code
  * un seul flush() final (evite le flush-par-save de la repo save())

Ticket #343 - 4/7 : scanner et synchroniseur de permissions RBAC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-04-14 16:56:50 +02:00
parent 7aa32b1972
commit 3b1f18b0e0
2 changed files with 237 additions and 0 deletions

View File

@@ -9,4 +9,31 @@ final class CoreModule
public const string ID = 'core';
public const string LABEL = 'Core';
public const bool REQUIRED = true;
/**
* Liste declarative des permissions RBAC exposees par le module Core.
*
* Consommee par la commande `app:sync-permissions` (SyncPermissionsCommand)
* qui se charge d'upserter ces entrees dans la table `permission`, de
* reactiver les codes precedemment marques orphelins et de marquer comme
* orphelins ceux qui ont disparu du code source.
*
* La cle `module` est auto-injectee par le sync command a partir de
* `self::ID`, il est donc inutile de la repeter dans chaque entree.
*
* Convention de nommage des codes : `module.resource[.sub].action` en
* snake_case, le prefixe module devant correspondre exactement a
* `self::ID` (verifie par la commande de synchronisation).
*
* @return array<int, array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'core.users.view', 'label' => 'Voir les utilisateurs'],
['code' => 'core.users.manage', 'label' => 'Gerer les utilisateurs (creer, editer, supprimer)'],
['code' => 'core.roles.manage', 'label' => 'Gerer les roles et permissions'],
['code' => 'core.permissions.view', 'label' => 'Voir la liste des permissions'],
];
}
}

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;
}
}