diff --git a/src/Module/Core/CoreModule.php b/src/Module/Core/CoreModule.php index 80fa614..d6d22a9 100644 --- a/src/Module/Core/CoreModule.php +++ b/src/Module/Core/CoreModule.php @@ -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 + */ + 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'], + ]; + } } diff --git a/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php b/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php new file mode 100644 index 0000000..a326b9b --- /dev/null +++ b/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php @@ -0,0 +1,210 @@ +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 `::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 + */ + private function collectDesiredPermissions(): array + { + /** @var array $moduleClasses */ + $moduleClasses = require $this->projectDir.'/config/modules.php'; + + $desired = []; + + foreach ($moduleClasses as $moduleClass) { + if (!method_exists($moduleClass, 'permissions')) { + continue; + } + + /** @var array> $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; + } +}