- 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>
211 lines
7.8 KiB
PHP
211 lines
7.8 KiB
PHP
<?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;
|
|
}
|
|
}
|