diff --git a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md index 73754eddb83a5..204410d29b9a1 100644 --- a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 7.4 --- + * Add `debug:security:role-hierarchy` command to dump role hierarchy graphs in the Mermaid.js flowchart format * Register alias for argument for password hasher when its key is not a class name: With the following configuration: diff --git a/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php new file mode 100644 index 0000000000000..7072c38246bf4 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Command; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Security\Core\Dumper\MermaidDirectionEnum; +use Symfony\Component\Security\Core\Dumper\MermaidDumper; +use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; + +/** + * Command to dump the role hierarchy as a Mermaid flowchart. + * + * @author Damien Fernandes + */ +#[AsCommand(name: 'debug:security:role-hierarchy', description: 'Dump the role hierarchy as a Mermaid flowchart')] +class SecurityRoleHierarchyDumpCommand extends Command +{ + public function __construct( + private readonly ?RoleHierarchyInterface $roleHierarchy = null, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->setDefinition([ + new InputOption( + name: 'direction', + shortcut: 'd', + mode: InputOption::VALUE_REQUIRED, + description: 'The direction of the flowchart ['.implode('|', $this->getAvailableDirections()).']', + default: MermaidDirectionEnum::TOP_TO_BOTTOM->value, + suggestedValues: $this->getAvailableDirections() + ), + ]) + ->setHelp(<<<'USAGE' +The %command.name% command dumps the role hierarchy in Mermaid format. + +Mermaid: %command.full_name% > roles.mmd +Mermaid with direction: %command.full_name% --direction=BT > roles.mmd +USAGE + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + if (null === $this->roleHierarchy) { + $io->getErrorStyle()->writeln('No role hierarchy is configured.'); + + return Command::SUCCESS; + } + + $direction = $input->getOption('direction'); + + if (!MermaidDirectionEnum::tryFrom($direction)) { + $io->getErrorStyle()->writeln('Invalid direction. Available options: '.implode('|', $this->getAvailableDirections()).''); + + return Command::FAILURE; + } + + $dumper = new MermaidDumper(); + $mermaidOutput = $dumper->dump($this->roleHierarchy, MermaidDirectionEnum::from($direction)); + + $output->writeln($mermaidOutput); + + return Command::SUCCESS; + } + + /** + * @return string[] + */ + private function getAvailableDirections(): array + { + return array_map(fn ($case) => $case->value, MermaidDirectionEnum::cases()); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/debug_console.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/debug_console.php index 74fa434926063..fe781681bed73 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/debug_console.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/debug_console.php @@ -12,6 +12,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use Symfony\Bundle\SecurityBundle\Command\DebugFirewallCommand; +use Symfony\Bundle\SecurityBundle\Command\SecurityRoleHierarchyDumpCommand; return static function (ContainerConfigurator $container) { $container->services() @@ -24,5 +25,11 @@ false, ]) ->tag('console.command', ['command' => 'debug:firewall']) + + ->set('security.command.role_hierarchy_dump', SecurityRoleHierarchyDumpCommand::class) + ->args([ + service('security.role_hierarchy'), + ]) + ->tag('console.command') ; }; diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.php new file mode 100644 index 0000000000000..a6d27fe63142d --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\Command; + +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\SecurityBundle\Command\SecurityRoleHierarchyDumpCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Security\Core\Role\RoleHierarchy; + +class SecurityRoleHierarchyDumpCommandTest extends TestCase +{ + public function testExecuteWithNoRoleHierarchy() + { + $command = new SecurityRoleHierarchyDumpCommand(); + $commandTester = new CommandTester($command); + + $exitCode = $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertStringContainsString('No role hierarchy is configured', $commandTester->getDisplay()); + } + + public function testExecuteWithRoleHierarchy() + { + $hierarchy = [ + 'ROLE_ADMIN' => ['ROLE_USER'], + 'ROLE_SUPER_ADMIN' => ['ROLE_ADMIN', 'ROLE_USER'], + ]; + + $roleHierarchy = new RoleHierarchy($hierarchy); + $command = new SecurityRoleHierarchyDumpCommand($roleHierarchy); + $commandTester = new CommandTester($command); + + $exitCode = $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $exitCode); + $output = $commandTester->getDisplay(); + $expectedOutput = << ROLE_USER + ROLE_SUPER_ADMIN --> ROLE_ADMIN + ROLE_SUPER_ADMIN --> ROLE_USER + +EXPECTED; + + $this->assertSame($expectedOutput, $output); + } + + public function testExecuteWithCustomDirection() + { + $hierarchy = [ + 'ROLE_ADMIN' => ['ROLE_USER'], + ]; + + $roleHierarchy = new RoleHierarchy($hierarchy); + $command = new SecurityRoleHierarchyDumpCommand($roleHierarchy); + $commandTester = new CommandTester($command); + + $exitCode = $commandTester->execute(['--direction' => 'BT']); + + $this->assertSame(Command::SUCCESS, $exitCode); + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('graph BT', $output); + } + + public function testExecuteWithInvalidDirection() + { + $hierarchy = [ + 'ROLE_ADMIN' => ['ROLE_USER'], + ]; + + $roleHierarchy = new RoleHierarchy($hierarchy); + $command = new SecurityRoleHierarchyDumpCommand($roleHierarchy); + $commandTester = new CommandTester($command); + + $exitCode = $commandTester->execute(['--direction' => 'INVALID']); + + $this->assertSame(Command::FAILURE, $exitCode); + $this->assertStringContainsString('Invalid direction', $commandTester->getDisplay()); + } +} diff --git a/src/Symfony/Component/Security/Core/CHANGELOG.md b/src/Symfony/Component/Security/Core/CHANGELOG.md index 128064166841f..e107810342433 100644 --- a/src/Symfony/Component/Security/Core/CHANGELOG.md +++ b/src/Symfony/Component/Security/Core/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.4 +--- + +* Add `MermaidDumper` to dump Role Hierarchy graphs in the Mermaid.js flowchart format + 7.3 --- diff --git a/src/Symfony/Component/Security/Core/Dumper/MermaidDirectionEnum.php b/src/Symfony/Component/Security/Core/Dumper/MermaidDirectionEnum.php new file mode 100644 index 0000000000000..b94af8701d1db --- /dev/null +++ b/src/Symfony/Component/Security/Core/Dumper/MermaidDirectionEnum.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Dumper; + +enum MermaidDirectionEnum: string +{ + case TOP_TO_BOTTOM = 'TB'; + case TOP_DOWN = 'TD'; + case BOTTOM_TO_TOP = 'BT'; + case RIGHT_TO_LEFT = 'RL'; + case LEFT_TO_RIGHT = 'LR'; +} diff --git a/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php b/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php new file mode 100644 index 0000000000000..ab1beac6e53f6 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Dumper; + +use Symfony\Component\Security\Core\Role\RoleHierarchy; +use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; + +/** + * MermaidDumper dumps a Mermaid flowchart describing role hierarchy. + * + * @author Damien Fernandes + */ +class MermaidDumper +{ + /** + * Dumps the role hierarchy as a Mermaid flowchart. + * + * @param RoleHierarchyInterface $roleHierarchy The role hierarchy to dump + * @param MermaidDirectionEnum $direction The direction of the flowchart + */ + public function dump(RoleHierarchyInterface $roleHierarchy, MermaidDirectionEnum $direction = MermaidDirectionEnum::TOP_TO_BOTTOM): string + { + $hierarchy = $this->extractHierarchy($roleHierarchy); + + if (!$hierarchy) { + return "graph {$direction->value}\n classDef default fill:#e1f5fe;"; + } + + $output = ["graph {$direction->value}"]; + $allRoles = $this->getAllRoles($hierarchy); + + foreach ($allRoles as $role) { + $output[] = $this->formatRoleNode($role); + } + + foreach ($hierarchy as $parentRole => $childRoles) { + foreach ($childRoles as $childRole) { + $output[] = " {$this->normalizeRoleName($parentRole)} --> {$this->normalizeRoleName($childRole)}"; + } + } + + return implode("\n", array_filter($output)); + } + + private function extractHierarchy(RoleHierarchyInterface $roleHierarchy): array + { + if (!$roleHierarchy instanceof RoleHierarchy) { + return []; + } + + $reflection = new \ReflectionClass(RoleHierarchy::class); + + if ($reflection->hasProperty('hierarchy')) { + $hierarchyProperty = $reflection->getProperty('hierarchy'); + + return $hierarchyProperty->getValue($roleHierarchy); + } + + return []; + } + + private function getAllRoles(array $hierarchy): array + { + $allRoles = []; + + foreach ($hierarchy as $parentRole => $childRoles) { + $allRoles[] = $parentRole; + foreach ($childRoles as $childRole) { + $allRoles[] = $childRole; + } + } + + return array_unique($allRoles); + } + + private function formatRoleNode(string $role): string + { + $escapedRole = $this->normalizeRoleName($role); + + return " {$escapedRole}"; + } + + /** + * Normalizes the role name by replacing non-alphanumeric characters with underscores. + */ + private function normalizeRoleName(string $role): ?string + { + return preg_replace('/[^a-zA-Z0-9_]/', '_', $role); + } +} diff --git a/src/Symfony/Component/Security/Core/Tests/Dumper/MermaidDumperTest.php b/src/Symfony/Component/Security/Core/Tests/Dumper/MermaidDumperTest.php new file mode 100644 index 0000000000000..5f3209cdd2e1a --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Dumper/MermaidDumperTest.php @@ -0,0 +1,134 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Tests\Dumper; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Security\Core\Dumper\MermaidDirectionEnum; +use Symfony\Component\Security\Core\Dumper\MermaidDumper; +use Symfony\Component\Security\Core\Role\RoleHierarchy; + +class MermaidDumperTest extends TestCase +{ + public function testDumpSimpleHierarchy() + { + $hierarchy = [ + 'ROLE_ADMIN' => ['ROLE_USER'], + 'ROLE_SUPER_ADMIN' => ['ROLE_ADMIN', 'ROLE_ALLOWED_TO_SWITCH'], + ]; + + $roleHierarchy = new RoleHierarchy($hierarchy); + $dumper = new MermaidDumper(); + $output = $dumper->dump($roleHierarchy); + + $this->assertStringContainsString('graph TB', $output); + $this->assertStringContainsString('ROLE_ADMIN', $output); + $this->assertStringContainsString('ROLE_USER', $output); + $this->assertStringContainsString('ROLE_SUPER_ADMIN', $output); + $this->assertStringContainsString('ROLE_ADMIN --> ROLE_USER', $output); + $this->assertStringContainsString('ROLE_SUPER_ADMIN --> ROLE_ADMIN', $output); + $this->assertStringContainsString('ROLE_SUPER_ADMIN --> ROLE_ALLOWED_TO_SWITCH', $output); + } + + public function testDumpWithDirection() + { + $hierarchy = [ + 'ROLE_ADMIN' => ['ROLE_USER'], + ]; + + $roleHierarchy = new RoleHierarchy($hierarchy); + $dumper = new MermaidDumper(); + $output = $dumper->dump($roleHierarchy, MermaidDirectionEnum::LEFT_TO_RIGHT); + + $this->assertStringContainsString('graph LR', $output); + } + + public function testDumpEmptyHierarchy() + { + $roleHierarchy = new RoleHierarchy([]); + $dumper = new MermaidDumper(); + $output = $dumper->dump($roleHierarchy); + + $this->assertStringContainsString('graph TB', $output); + $this->assertStringContainsString('classDef default fill:#e1f5fe;', $output); + } + + public function testDumpComplexHierarchy() + { + $hierarchy = [ + 'ROLE_SUPER_ADMIN' => ['ROLE_ADMIN', 'ROLE_ALLOWED_TO_SWITCH'], + 'ROLE_ADMIN' => ['ROLE_USER'], + 'ROLE_MANAGER' => ['ROLE_USER'], + 'ROLE_EDITOR' => ['ROLE_USER'], + ]; + + $roleHierarchy = new RoleHierarchy($hierarchy); + $dumper = new MermaidDumper(); + $output = $dumper->dump($roleHierarchy); + + $this->assertStringContainsString('ROLE_SUPER_ADMIN', $output); + $this->assertStringContainsString('ROLE_ADMIN', $output); + $this->assertStringContainsString('ROLE_MANAGER', $output); + $this->assertStringContainsString('ROLE_EDITOR', $output); + $this->assertStringContainsString('ROLE_USER', $output); + $this->assertStringContainsString('ROLE_ALLOWED_TO_SWITCH', $output); + + $this->assertStringContainsString('ROLE_SUPER_ADMIN --> ROLE_ADMIN', $output); + $this->assertStringContainsString('ROLE_SUPER_ADMIN --> ROLE_ALLOWED_TO_SWITCH', $output); + $this->assertStringContainsString('ROLE_ADMIN --> ROLE_USER', $output); + $this->assertStringContainsString('ROLE_MANAGER --> ROLE_USER', $output); + $this->assertStringContainsString('ROLE_EDITOR --> ROLE_USER', $output); + } + + public function testInvalidDirection() + { + $this->expectException(\TypeError::class); + + $dumper = new MermaidDumper(); + $dumper->dump(new RoleHierarchy([]), 'INVALID'); + } + + /** + * @dataProvider dataProviderValidDirection + */ + public function testValidDirections(MermaidDirectionEnum $direction) + { + $this->expectNotToPerformAssertions(); + $dumper = new MermaidDumper(); + $dumper->dump(new RoleHierarchy([]), $direction); + } + + public static function dataProviderValidDirection() + { + return [ + [MermaidDirectionEnum::TOP_TO_BOTTOM], + [MermaidDirectionEnum::TOP_DOWN], + [MermaidDirectionEnum::BOTTOM_TO_TOP], + [MermaidDirectionEnum::RIGHT_TO_LEFT], + [MermaidDirectionEnum::LEFT_TO_RIGHT], + ]; + } + + public function testRoleNameEscaping() + { + $hierarchy = [ + 'ROLE_ADMIN-TEST' => ['ROLE_USER.SPECIAL'], + ]; + + $roleHierarchy = new RoleHierarchy($hierarchy); + $dumper = new MermaidDumper(); + $output = $dumper->dump($roleHierarchy); + + $this->assertStringContainsString('ROLE_ADMIN_TEST', $output); + $this->assertStringContainsString('ROLE_USER_SPECIAL', $output); + $this->assertStringContainsString('ROLE_ADMIN_TEST --> ROLE_USER_SPECIAL', $output); + } +} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy