mirror of
				https://gitlab.com/Chill-Projet/chill-bundles.git
				synced 2025-10-31 01:08:26 +00:00 
			
		
		
		
	Merge branch 'add-permission-list-command' into 'master'
Add `RoleDumper` and `DumpListPermissionsCommand` to generate a markdown list of permissions See merge request Chill-Projet/chill-bundles!874
This commit is contained in:
		
							
								
								
									
										6
									
								
								.changes/unreleased/Feature-20250904-181032.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.changes/unreleased/Feature-20250904-181032.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| @@ -0,0 +1,35 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Command; | ||||
|  | ||||
| use Chill\MainBundle\Security\RoleDumper; | ||||
| use Symfony\Component\Console\Attribute\AsCommand; | ||||
| use Symfony\Component\Console\Command\Command; | ||||
| use Symfony\Component\Console\Input\InputInterface; | ||||
| use Symfony\Component\Console\Output\OutputInterface; | ||||
|  | ||||
| #[AsCommand(name: 'chill:main:dump-list-permissions', description: 'Print a markdown reference of permissions (roles) grouped by title with dependencies).')] | ||||
| final class DumpListPermissionsCommand extends Command | ||||
| { | ||||
|     public function __construct(private readonly RoleDumper $roleDumper) | ||||
|     { | ||||
|         parent::__construct(); | ||||
|     } | ||||
|  | ||||
|     protected function execute(InputInterface $input, OutputInterface $output): int | ||||
|     { | ||||
|         $markdown = $this->roleDumper->dumpAsMarkdown(); | ||||
|         $output->writeln($markdown); | ||||
|  | ||||
|         return Command::SUCCESS; | ||||
|     } | ||||
| } | ||||
| @@ -20,7 +20,7 @@ use Chill\MainBundle\Repository\CenterRepositoryInterface; | ||||
| use Chill\MainBundle\Repository\RegroupmentRepositoryInterface; | ||||
|  | ||||
| /** | ||||
|  * @phpstan-type NormalizedData array{centers: array{centers: list<int>, regroupments: list<int>}, export: array{form: array<string, mixed>, version: int}, filters: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, aggregators: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, pick_formatter: string, formatter: array{form: array<string, mixed>, version: int}} | ||||
|  * @phpstan-type NormalizedData array{centers: array{centers: list<int>, regroupments: list<int>}, export: array{form: array<string, mixed>, version: int}, filters: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, aggregators: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, pick_formatter?: string, formatter: array{form: array<string, mixed>, version: int}} | ||||
|  */ | ||||
| class ExportConfigNormalizer | ||||
| { | ||||
|   | ||||
							
								
								
									
										86
									
								
								src/Bundle/ChillMainBundle/Security/RoleDumper.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								src/Bundle/ChillMainBundle/Security/RoleDumper.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,86 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Security; | ||||
|  | ||||
| use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
|  | ||||
| final readonly class RoleDumper | ||||
| { | ||||
|     public function __construct( | ||||
|         private RoleProvider $roleProvider, | ||||
|         private RoleHierarchyInterface $roleHierarchy, | ||||
|         private TranslatorInterface $translator, | ||||
|     ) {} | ||||
|  | ||||
|     public function dumpAsMarkdown(): string | ||||
|     { | ||||
|         $roles = $this->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 | ||||
|     } | ||||
| } | ||||
| @@ -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) { | ||||
|   | ||||
							
								
								
									
										98
									
								
								src/Bundle/ChillMainBundle/Tests/Security/RoleDumperTest.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								src/Bundle/ChillMainBundle/Tests/Security/RoleDumperTest.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Tests\Security; | ||||
|  | ||||
| use Chill\MainBundle\Security\ProvideRoleHierarchyInterface; | ||||
| use Chill\MainBundle\Security\RoleDumper; | ||||
| use Chill\MainBundle\Security\RoleProvider; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class RoleDumperTest extends TestCase | ||||
| { | ||||
|     public function testDumpAsMarkdownGroupsByTitleTranslatesAndListsDependencies(): void | ||||
|     { | ||||
|         // Fake provider with two groups | ||||
|         $provider = new class () implements ProvideRoleHierarchyInterface { | ||||
|             public const R_PERSON_SEE = 'CHILL_PERSON_SEE'; | ||||
|             public const R_PERSON_UPDATE = 'CHILL_PERSON_UPDATE'; | ||||
|             public const R_REPORT_SEE = 'CHILL_REPORT_SEE'; | ||||
|  | ||||
|             public function getRoles(): array | ||||
|             { | ||||
|                 return [self::R_PERSON_SEE, self::R_PERSON_UPDATE, self::R_REPORT_SEE]; | ||||
|             } | ||||
|  | ||||
|             public function getRolesWithoutScope(): array | ||||
|             { | ||||
|                 // In this test, assume REPORT_SEE does not need scope, others do | ||||
|                 return [self::R_REPORT_SEE]; | ||||
|             } | ||||
|  | ||||
|             public function getRolesWithHierarchy(): array | ||||
|             { | ||||
|                 return [ | ||||
|                     'Person' => [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); | ||||
|     } | ||||
| } | ||||
| @@ -80,3 +80,7 @@ services: | ||||
|     Chill\MainBundle\Command\SynchronizeEntityInfoViewsCommand: | ||||
|         tags: | ||||
|             - {name: console.command} | ||||
|  | ||||
|     Chill\MainBundle\Command\DumpListPermissionsCommand: | ||||
|         autoconfigure: true | ||||
|         autowire: true | ||||
|   | ||||
		Reference in New Issue
	
	Block a user