diff --git a/.changes/unreleased/Feature-20250904-181032.yaml b/.changes/unreleased/Feature-20250904-181032.yaml new file mode 100644 index 000000000..145299a36 --- /dev/null +++ b/.changes/unreleased/Feature-20250904-181032.yaml @@ -0,0 +1,6 @@ +kind: Feature +body: Add a command to generate a list of permissions +time: 2025-09-04T18:10:32.334524026+02:00 +custom: + Issue: "" + SchemaChange: No schema change diff --git a/src/Bundle/ChillMainBundle/Command/DumpListPermissionsCommand.php b/src/Bundle/ChillMainBundle/Command/DumpListPermissionsCommand.php new file mode 100644 index 000000000..cd5510eb3 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Command/DumpListPermissionsCommand.php @@ -0,0 +1,35 @@ +roleDumper->dumpAsMarkdown(); + $output->writeln($markdown); + + return Command::SUCCESS; + } +} diff --git a/src/Bundle/ChillMainBundle/Export/ExportConfigNormalizer.php b/src/Bundle/ChillMainBundle/Export/ExportConfigNormalizer.php index b71b2d102..d1ec99cd3 100644 --- a/src/Bundle/ChillMainBundle/Export/ExportConfigNormalizer.php +++ b/src/Bundle/ChillMainBundle/Export/ExportConfigNormalizer.php @@ -20,7 +20,7 @@ use Chill\MainBundle\Repository\CenterRepositoryInterface; use Chill\MainBundle\Repository\RegroupmentRepositoryInterface; /** - * @phpstan-type NormalizedData array{centers: array{centers: list, regroupments: list}, export: array{form: array, version: int}, filters: array, version: int}>, aggregators: array, version: int}>, pick_formatter: string, formatter: array{form: array, version: int}} + * @phpstan-type NormalizedData array{centers: array{centers: list, regroupments: list}, export: array{form: array, version: int}, filters: array, version: int}>, aggregators: array, version: int}>, pick_formatter?: string, formatter: array{form: array, version: int}} */ class ExportConfigNormalizer { diff --git a/src/Bundle/ChillMainBundle/Security/RoleDumper.php b/src/Bundle/ChillMainBundle/Security/RoleDumper.php new file mode 100644 index 000000000..4bd9b1944 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Security/RoleDumper.php @@ -0,0 +1,86 @@ +roleProvider->getRoles(); + $rolesWithoutScopes = $this->roleProvider->getRolesWithoutScopes(); + + // Group roles by title + $groups = []; + foreach ($roles as $role) { + $title = $this->roleProvider->getRoleTitle($role); + $title ??= 'Other'; + $groups[$title][] = $role; + } + + // Sort groups by title + ksort($groups, SORT_NATURAL | SORT_FLAG_CASE); + + $lines = []; + foreach ($groups as $title => $roleList) { + // Sort roles by translated label for deterministic output + usort($roleList, function (string $a, string $b): int { + $ta = $this->translator->trans($a); + $tb = $this->translator->trans($b); + + return strcasecmp($ta, $tb); + }); + + $translatedTitle = $this->translator->trans($title); + $lines[] = '## '.$translatedTitle; + + foreach ($roleList as $role) { + // Translate primary role + $translatedRole = $this->translator->trans($role); + + // Scope marker: (S) if needs scope, (~~S~~) if no scope required + $needsScope = !in_array($role, $rolesWithoutScopes, true); + $scopeMarker = $needsScope ? '(S)' : '(~~S~~)'; + + // Compute dependent roles from hierarchy (exclude itself) + $reachable = $this->roleHierarchy->getReachableRoleNames([$role]); + $dependents = array_values(array_filter($reachable, static fn (string $r): bool => $r !== $role)); + + // Translate dependents and sort deterministically + $translatedDependents = array_map(fn (string $r) => $this->translator->trans($r), $dependents); + sort($translatedDependents, SORT_NATURAL | SORT_FLAG_CASE); + + if (count($translatedDependents) > 0) { + $lines[] = sprintf('- **%s** %s: %s', $translatedRole, $scopeMarker, implode(', ', $translatedDependents)); + } else { + $lines[] = sprintf('- **%s** %s', $translatedRole, $scopeMarker); + } + } + + // Add a blank line between groups + $lines[] = ''; + } + + // Trim possible trailing blank line + $markdown = rtrim(implode("\n", $lines)); + + return $markdown."\n"; // End with newline for POSIX friendliness + } +} diff --git a/src/Bundle/ChillMainBundle/Security/RoleProvider.php b/src/Bundle/ChillMainBundle/Security/RoleProvider.php index ccaa92409..738c47f36 100644 --- a/src/Bundle/ChillMainBundle/Security/RoleProvider.php +++ b/src/Bundle/ChillMainBundle/Security/RoleProvider.php @@ -52,12 +52,8 @@ class RoleProvider /** * Get the title for each role. - * - * @param string $role - * - * @return string the title of the role */ - public function getRoleTitle($role) + public function getRoleTitle(string $role): ?string { $this->initializeRolesTitlesCache(); @@ -73,7 +69,7 @@ class RoleProvider /** * initialize the array for caching role and titles. */ - private function initializeRolesTitlesCache() + private function initializeRolesTitlesCache(): void { // break if already initialized if (null !== $this->rolesTitlesCache) { diff --git a/src/Bundle/ChillMainBundle/Tests/Security/RoleDumperTest.php b/src/Bundle/ChillMainBundle/Tests/Security/RoleDumperTest.php new file mode 100644 index 000000000..8b7751c6f --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Security/RoleDumperTest.php @@ -0,0 +1,98 @@ + [self::R_PERSON_SEE, self::R_PERSON_UPDATE], + 'Report' => [self::R_REPORT_SEE], + ]; + } + }; + + $roleProvider = new RoleProvider([$provider]); + + // Fake role hierarchy: UPDATE implies SEE; others none + $roleHierarchy = new class () implements RoleHierarchyInterface { + public function getReachableRoleNames(array $roles): array + { + $output = []; + foreach ($roles as $r) { + $output[] = $r; + if ('CHILL_PERSON_UPDATE' === $r) { + $output[] = 'CHILL_PERSON_SEE'; + } + } + + return array_values(array_unique($output)); + } + }; + + // Fake translator that clearly shows translation applied + $translator = new class () implements TranslatorInterface { + public function trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string + { + return 'T('.$id.')'; + } + + public function getLocale(): string + { + return 'en'; + } + }; + + $dumper = new RoleDumper($roleProvider, $roleHierarchy, $translator); + $md = $dumper->dumpAsMarkdown(); + + $expected = "## T(Person)\n" + ."- **T(CHILL_PERSON_SEE)** (S)\n" + ."- **T(CHILL_PERSON_UPDATE)** (S): T(CHILL_PERSON_SEE)\n\n" + ."## T(Report)\n" + ."- **T(CHILL_REPORT_SEE)** (~~S~~)\n"; + + self::assertSame($expected, $md); + } +} diff --git a/src/Bundle/ChillMainBundle/config/services/command.yaml b/src/Bundle/ChillMainBundle/config/services/command.yaml index 158bf3fab..313ca28c5 100644 --- a/src/Bundle/ChillMainBundle/config/services/command.yaml +++ b/src/Bundle/ChillMainBundle/config/services/command.yaml @@ -80,3 +80,7 @@ services: Chill\MainBundle\Command\SynchronizeEntityInfoViewsCommand: tags: - {name: console.command} + + Chill\MainBundle\Command\DumpListPermissionsCommand: + autoconfigure: true + autowire: true