From 02f276192b46be23c533d628a762f7252ce2c73b Mon Sep 17 00:00:00 2001 From: Damien Fernandes Date: Fri, 4 Jul 2025 08:44:50 +0200 Subject: [PATCH 01/13] feat(security): add command to dump role hierarchy as a mermaid chart --- .../SecurityRoleHierarchyDumpCommand.php | 112 +++++++++++++++ .../Resources/config/debug_console.php | 7 + .../SecurityRoleHierarchyDumpCommandTest.php | 104 ++++++++++++++ .../Security/Core/Dumper/MermaidDumper.php | 129 +++++++++++++++++ .../Core/Tests/Dumper/MermaidDumperTest.php | 130 ++++++++++++++++++ 5 files changed, 482 insertions(+) create mode 100644 src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.php create mode 100644 src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php create mode 100644 src/Symfony/Component/Security/Core/Tests/Dumper/MermaidDumperTest.php diff --git a/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php new file mode 100644 index 0000000000000..781df753b3d65 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php @@ -0,0 +1,112 @@ + + * + * 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\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +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 Your Name + * + * @final + */ +#[AsCommand(name: 'debug:security:role-hierarchy', description: 'Dump the role hierarchy as a Mermaid flowchart')] +class SecurityRoleHierarchyDumpCommand extends Command +{ + private const DIRECTION_OPTIONS = [ + 'TB', + 'TD', + 'BT', + 'RL', + 'LR', + ]; + + public function __construct( + private readonly ?RoleHierarchyInterface $roleHierarchy = null, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->setDefinition([ + new InputOption('direction', 'd', InputOption::VALUE_REQUIRED, 'The direction of the flowchart ['.implode('|', self::DIRECTION_OPTIONS).']', 'TB'), + new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'The output format', 'mermaid'), + ]) + ->setHelp(<<<'USAGE' +The %command.name% command dumps the role hierarchy in different formats. + +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 + { + if (null === $this->roleHierarchy) { + $output->writeln('No role hierarchy is configured.'); + return Command::SUCCESS; + } + + $direction = $input->getOption('direction'); + $format = $input->getOption('format'); + + if ('mermaid' !== $format) { + $output->writeln('Only "mermaid" format is currently supported.'); + return Command::FAILURE; + } + + if (!in_array($direction, self::DIRECTION_OPTIONS, true)) { + $output->writeln('Invalid direction. Available options: '.implode(', ', self::DIRECTION_OPTIONS).''); + return Command::FAILURE; + } + + // Map console direction options to dumper constants + $directionMap = [ + 'TB' => MermaidDumper::DIRECTION_TOP_TO_BOTTOM, + 'TD' => MermaidDumper::DIRECTION_TOP_DOWN, + 'BT' => MermaidDumper::DIRECTION_BOTTOM_TO_TOP, + 'RL' => MermaidDumper::DIRECTION_RIGHT_TO_LEFT, + 'LR' => MermaidDumper::DIRECTION_LEFT_TO_RIGHT, + ]; + + $dumper = new MermaidDumper($directionMap[$direction]); + $mermaidOutput = $dumper->dump($this->roleHierarchy); + + $output->writeln($mermaidOutput); + + return Command::SUCCESS; + } + + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestOptionValuesFor('direction')) { + $suggestions->suggestValues(self::DIRECTION_OPTIONS); + } + + if ($input->mustSuggestOptionValuesFor('format')) { + $suggestions->suggestValues(['mermaid']); + } + } +} 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..bd10d66658439 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.php @@ -0,0 +1,104 @@ + + * + * 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\Application; +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->assertEquals(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->assertEquals(Command::SUCCESS, $exitCode); + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('graph TB', $output); + $this->assertStringContainsString('ROLE_ADMIN --> ROLE_USER', $output); + $this->assertStringContainsString('ROLE_SUPER_ADMIN --> ROLE_ADMIN', $output); + $this->assertStringContainsString('ROLE_SUPER_ADMIN --> ROLE_USER', $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->assertEquals(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->assertEquals(Command::FAILURE, $exitCode); + $this->assertStringContainsString('Invalid direction', $commandTester->getDisplay()); + } + + public function testExecuteWithInvalidFormat() + { + $hierarchy = [ + 'ROLE_ADMIN' => ['ROLE_USER'], + ]; + + $roleHierarchy = new RoleHierarchy($hierarchy); + $command = new SecurityRoleHierarchyDumpCommand($roleHierarchy); + $commandTester = new CommandTester($command); + + $exitCode = $commandTester->execute(['--format' => 'dot']); + + $this->assertEquals(Command::FAILURE, $exitCode); + $this->assertStringContainsString('Only "mermaid" format is currently supported', $commandTester->getDisplay()); + } + +} 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..bebf0cfd3d850 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php @@ -0,0 +1,129 @@ + + * + * 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\RoleHierarchyInterface; + +/** + * MermaidDumper dumps a Mermaid flowchart describing role hierarchy. + * + * @author Damien Fernandes + */ +class MermaidDumper +{ + public const DIRECTION_TOP_TO_BOTTOM = 'TB'; + public const DIRECTION_TOP_DOWN = 'TD'; + public const DIRECTION_BOTTOM_TO_TOP = 'BT'; + public const DIRECTION_RIGHT_TO_LEFT = 'RL'; + public const DIRECTION_LEFT_TO_RIGHT = 'LR'; + + private const VALID_DIRECTIONS = [ + self::DIRECTION_TOP_TO_BOTTOM, + self::DIRECTION_TOP_DOWN, + self::DIRECTION_BOTTOM_TO_TOP, + self::DIRECTION_RIGHT_TO_LEFT, + self::DIRECTION_LEFT_TO_RIGHT, + ]; + + public function __construct( + private readonly string $direction = self::DIRECTION_TOP_TO_BOTTOM, + ) { + if (!in_array($direction, self::VALID_DIRECTIONS, true)) { + throw new \InvalidArgumentException(sprintf( + 'Direction "%s" is not valid, valid directions are: "%s".', + $direction, + implode('", "', self::VALID_DIRECTIONS) + )); + } + } + + /** + * Dumps the role hierarchy as a Mermaid flowchart. + * + * @param RoleHierarchyInterface $roleHierarchy The role hierarchy to dump + */ + public function dump(RoleHierarchyInterface $roleHierarchy): string + { + $hierarchy = $this->extractHierarchy($roleHierarchy); + + if ([] === $hierarchy) { + return "graph {$this->direction}\n classDef default fill:#e1f5fe;"; + } + + $output = ["graph {$this->direction}"]; + $allRoles = $this->getAllRoles($hierarchy); + + // Add role nodes + foreach ($allRoles as $role) { + $output[] = $this->formatRoleNode($role); + } + + // Add hierarchy relationships (parent -> child) + foreach ($hierarchy as $parentRole => $childRoles) { + foreach ($childRoles as $childRole) { + $output[] = " {$this->escapeRoleName($parentRole)} --> {$this->escapeRoleName($childRole)}"; + } + } + + return implode("\n", array_filter($output)); + } + + /** + * Extracts the role hierarchy from the RoleHierarchyInterface. + */ + private function extractHierarchy(RoleHierarchyInterface $roleHierarchy): array + { + $reflection = new \ReflectionClass($roleHierarchy); + + if ($reflection->hasProperty('hierarchy')) { + $hierarchyProperty = $reflection->getProperty('hierarchy'); + return $hierarchyProperty->getValue($roleHierarchy); + } + + return []; + } + + /** + * Gets all unique roles from the hierarchy. + */ + private function getAllRoles(array $hierarchy): array + { + $allRoles = []; + + foreach ($hierarchy as $parentRole => $childRoles) { + $allRoles[] = $parentRole; + foreach ($childRoles as $childRole) { + $allRoles[] = $childRole; + } + } + + return array_unique($allRoles); + } + + /** + * Formats a role node for Mermaid. + */ + private function formatRoleNode(string $role): string + { + $escapedRole = $this->escapeRoleName($role); + return " {$escapedRole}"; + } + + /** + * Escapes role name for use as Mermaid node ID. + */ + private function escapeRoleName(string $role): string + { + // Replace any non-alphanumeric characters with underscores + 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..14f8519b90c62 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Dumper/MermaidDumperTest.php @@ -0,0 +1,130 @@ + + * + * 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\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(MermaidDumper::DIRECTION_LEFT_TO_RIGHT); + $output = $dumper->dump($roleHierarchy); + + $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); + + // Check that all roles are present + $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); + + // Check relationships + $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(\InvalidArgumentException::class); + $this->expectExceptionMessage('Direction "INVALID" is not valid'); + + new MermaidDumper('INVALID'); + } + + public function testValidDirections() + { + $validDirections = [ + MermaidDumper::DIRECTION_TOP_TO_BOTTOM, + MermaidDumper::DIRECTION_TOP_DOWN, + MermaidDumper::DIRECTION_BOTTOM_TO_TOP, + MermaidDumper::DIRECTION_RIGHT_TO_LEFT, + MermaidDumper::DIRECTION_LEFT_TO_RIGHT, + ]; + + foreach ($validDirections as $direction) { + $dumper = new MermaidDumper($direction); + $this->assertInstanceOf(MermaidDumper::class, $dumper); + } + } + + 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); + } +} From dcfe489915e9dac76106c724f1429aa8aee81744 Mon Sep 17 00:00:00 2001 From: Damien Fernandes Date: Fri, 4 Jul 2025 12:00:06 +0200 Subject: [PATCH 02/13] add changelog entries --- src/Symfony/Bundle/SecurityBundle/CHANGELOG.md | 1 + src/Symfony/Component/Security/Core/CHANGELOG.md | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md index 73754eddb83a5..46e5124d1b7bf 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/Component/Security/Core/CHANGELOG.md b/src/Symfony/Component/Security/Core/CHANGELOG.md index 128064166841f..6a2808aec9e96 100644 --- a/src/Symfony/Component/Security/Core/CHANGELOG.md +++ b/src/Symfony/Component/Security/Core/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.4 +--- + +* Added `MermaidDumper` to dump Role Hierarchy graphs in the Mermaid.js flowchart format + 7.3 --- From d1a118c915e83a8cbbc127d34cbb3c282c9a6257 Mon Sep 17 00:00:00 2001 From: Damien Fernandes Date: Fri, 4 Jul 2025 12:02:41 +0200 Subject: [PATCH 03/13] fix author name --- .../Command/SecurityRoleHierarchyDumpCommand.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php index 781df753b3d65..8a354e48989fe 100644 --- a/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php +++ b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php @@ -24,9 +24,8 @@ /** * Command to dump the role hierarchy as a Mermaid flowchart. * - * @author Your Name + * @author Damien Fernandes * - * @final */ #[AsCommand(name: 'debug:security:role-hierarchy', description: 'Dump the role hierarchy as a Mermaid flowchart')] class SecurityRoleHierarchyDumpCommand extends Command From c120b3c349f89bbf9938c54311dc413b969b90c5 Mon Sep 17 00:00:00 2001 From: Damien Fernandes Date: Sat, 5 Jul 2025 16:21:48 +0200 Subject: [PATCH 04/13] review: use dumper direction const --- .../Command/SecurityRoleHierarchyDumpCommand.php | 16 ++++------------ .../Security/Core/Dumper/MermaidDumper.php | 2 +- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php index 8a354e48989fe..db7bd9961267e 100644 --- a/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php +++ b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php @@ -30,14 +30,6 @@ #[AsCommand(name: 'debug:security:role-hierarchy', description: 'Dump the role hierarchy as a Mermaid flowchart')] class SecurityRoleHierarchyDumpCommand extends Command { - private const DIRECTION_OPTIONS = [ - 'TB', - 'TD', - 'BT', - 'RL', - 'LR', - ]; - public function __construct( private readonly ?RoleHierarchyInterface $roleHierarchy = null, ) { @@ -48,7 +40,7 @@ protected function configure(): void { $this ->setDefinition([ - new InputOption('direction', 'd', InputOption::VALUE_REQUIRED, 'The direction of the flowchart ['.implode('|', self::DIRECTION_OPTIONS).']', 'TB'), + new InputOption('direction', 'd', InputOption::VALUE_REQUIRED, 'The direction of the flowchart ['.implode('|', MermaidDumper::VALID_DIRECTIONS).']', MermaidDumper::DIRECTION_TOP_TO_BOTTOM), new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'The output format', 'mermaid'), ]) ->setHelp(<<<'USAGE' @@ -76,8 +68,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - if (!in_array($direction, self::DIRECTION_OPTIONS, true)) { - $output->writeln('Invalid direction. Available options: '.implode(', ', self::DIRECTION_OPTIONS).''); + if (!in_array($direction, MermaidDumper::VALID_DIRECTIONS, true)) { + $output->writeln('Invalid direction. Available options: '.implode(', ', MermaidDumper::VALID_DIRECTIONS).''); return Command::FAILURE; } @@ -101,7 +93,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void { if ($input->mustSuggestOptionValuesFor('direction')) { - $suggestions->suggestValues(self::DIRECTION_OPTIONS); + $suggestions->suggestValues(MermaidDumper::VALID_DIRECTIONS); } if ($input->mustSuggestOptionValuesFor('format')) { diff --git a/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php b/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php index bebf0cfd3d850..8d8ce1e595ef5 100644 --- a/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php +++ b/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php @@ -26,7 +26,7 @@ class MermaidDumper public const DIRECTION_RIGHT_TO_LEFT = 'RL'; public const DIRECTION_LEFT_TO_RIGHT = 'LR'; - private const VALID_DIRECTIONS = [ + public const VALID_DIRECTIONS = [ self::DIRECTION_TOP_TO_BOTTOM, self::DIRECTION_TOP_DOWN, self::DIRECTION_BOTTOM_TO_TOP, From bac240e05b5f885d15b9242b0f9d86c7bef4991e Mon Sep 17 00:00:00 2001 From: Damien Fernandes Date: Sat, 5 Jul 2025 16:25:55 +0200 Subject: [PATCH 05/13] review: test the string --- .../SecurityRoleHierarchyDumpCommandTest.php | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.php index bd10d66658439..87244bbe06a66 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.php @@ -46,10 +46,18 @@ public function testExecuteWithRoleHierarchy() $this->assertEquals(Command::SUCCESS, $exitCode); $output = $commandTester->getDisplay(); - $this->assertStringContainsString('graph TB', $output); - $this->assertStringContainsString('ROLE_ADMIN --> ROLE_USER', $output); - $this->assertStringContainsString('ROLE_SUPER_ADMIN --> ROLE_ADMIN', $output); - $this->assertStringContainsString('ROLE_SUPER_ADMIN --> ROLE_USER', $output); + $expectedOutput = << ROLE_USER + ROLE_SUPER_ADMIN --> ROLE_ADMIN + ROLE_SUPER_ADMIN --> ROLE_USER + +EXPECTED; + + $this->assertEquals($expectedOutput, $output); } public function testExecuteWithCustomDirection() From 8e135fed2ac8c54bab81b043bc5a4c639d81236f Mon Sep 17 00:00:00 2001 From: Damien Fernandes Date: Sat, 5 Jul 2025 16:42:37 +0200 Subject: [PATCH 06/13] chore: psalm fix --- src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php b/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php index 8d8ce1e595ef5..f3eb293fbb0c2 100644 --- a/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php +++ b/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php @@ -121,7 +121,7 @@ private function formatRoleNode(string $role): string /** * Escapes role name for use as Mermaid node ID. */ - private function escapeRoleName(string $role): string + private function escapeRoleName(string $role): ?string { // Replace any non-alphanumeric characters with underscores return preg_replace('/[^a-zA-Z0-9_]/', '_', $role); From a61751b18c410c4e59151638fcd978c8929d6721 Mon Sep 17 00:00:00 2001 From: Damien Fernandes Date: Sat, 5 Jul 2025 17:16:53 +0200 Subject: [PATCH 07/13] review: typo fixes --- src/Symfony/Bundle/SecurityBundle/CHANGELOG.md | 2 +- .../Command/SecurityRoleHierarchyDumpCommand.php | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md index 46e5124d1b7bf..204410d29b9a1 100644 --- a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md @@ -4,7 +4,7 @@ CHANGELOG 7.4 --- - * Add `debug:security:role-hierarchy` command to dump Role Hierarchy graphs in the Mermaid.js flowchart format + * 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 index db7bd9961267e..2c2521ef987f6 100644 --- a/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php +++ b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php @@ -24,8 +24,7 @@ /** * Command to dump the role hierarchy as a Mermaid flowchart. * - * @author Damien Fernandes - * + * @author Damien Fernandes */ #[AsCommand(name: 'debug:security:role-hierarchy', description: 'Dump the role hierarchy as a Mermaid flowchart')] class SecurityRoleHierarchyDumpCommand extends Command From c39867bc0111bc36b24afd232ccf944291763a6f Mon Sep 17 00:00:00 2001 From: Damien Fernandes Date: Mon, 7 Jul 2025 17:29:44 +0200 Subject: [PATCH 08/13] review cs and tests --- .../SecurityRoleHierarchyDumpCommand.php | 1 - .../SecurityRoleHierarchyDumpCommandTest.php | 12 ++++---- .../Component/Security/Core/CHANGELOG.md | 2 +- .../Security/Core/Dumper/MermaidDumper.php | 28 ++++++------------- .../Core/Tests/Dumper/MermaidDumperTest.php | 25 +++++++---------- 5 files changed, 25 insertions(+), 43 deletions(-) diff --git a/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php index 2c2521ef987f6..85425070191f5 100644 --- a/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php +++ b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php @@ -72,7 +72,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - // Map console direction options to dumper constants $directionMap = [ 'TB' => MermaidDumper::DIRECTION_TOP_TO_BOTTOM, 'TD' => MermaidDumper::DIRECTION_TOP_DOWN, diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.php index 87244bbe06a66..5b9840ea0a254 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.php @@ -27,7 +27,7 @@ public function testExecuteWithNoRoleHierarchy() $exitCode = $commandTester->execute([]); - $this->assertEquals(Command::SUCCESS, $exitCode); + $this->assertSame(Command::SUCCESS, $exitCode); $this->assertStringContainsString('No role hierarchy is configured', $commandTester->getDisplay()); } @@ -44,7 +44,7 @@ public function testExecuteWithRoleHierarchy() $exitCode = $commandTester->execute([]); - $this->assertEquals(Command::SUCCESS, $exitCode); + $this->assertSame(Command::SUCCESS, $exitCode); $output = $commandTester->getDisplay(); $expectedOutput = <<assertEquals($expectedOutput, $output); + $this->assertSame($expectedOutput, $output); } public function testExecuteWithCustomDirection() @@ -72,7 +72,7 @@ public function testExecuteWithCustomDirection() $exitCode = $commandTester->execute(['--direction' => 'BT']); - $this->assertEquals(Command::SUCCESS, $exitCode); + $this->assertSame(Command::SUCCESS, $exitCode); $output = $commandTester->getDisplay(); $this->assertStringContainsString('graph BT', $output); } @@ -89,7 +89,7 @@ public function testExecuteWithInvalidDirection() $exitCode = $commandTester->execute(['--direction' => 'INVALID']); - $this->assertEquals(Command::FAILURE, $exitCode); + $this->assertSame(Command::FAILURE, $exitCode); $this->assertStringContainsString('Invalid direction', $commandTester->getDisplay()); } @@ -105,7 +105,7 @@ public function testExecuteWithInvalidFormat() $exitCode = $commandTester->execute(['--format' => 'dot']); - $this->assertEquals(Command::FAILURE, $exitCode); + $this->assertSame(Command::FAILURE, $exitCode); $this->assertStringContainsString('Only "mermaid" format is currently supported', $commandTester->getDisplay()); } diff --git a/src/Symfony/Component/Security/Core/CHANGELOG.md b/src/Symfony/Component/Security/Core/CHANGELOG.md index 6a2808aec9e96..e107810342433 100644 --- a/src/Symfony/Component/Security/Core/CHANGELOG.md +++ b/src/Symfony/Component/Security/Core/CHANGELOG.md @@ -4,7 +4,7 @@ CHANGELOG 7.4 --- -* Added `MermaidDumper` to dump Role Hierarchy graphs in the Mermaid.js flowchart format +* Add `MermaidDumper` to dump Role Hierarchy graphs in the Mermaid.js flowchart format 7.3 --- diff --git a/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php b/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php index f3eb293fbb0c2..6174250193f0f 100644 --- a/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php +++ b/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php @@ -16,7 +16,7 @@ /** * MermaidDumper dumps a Mermaid flowchart describing role hierarchy. * - * @author Damien Fernandes + * @author Damien Fernandes */ class MermaidDumper { @@ -37,8 +37,8 @@ class MermaidDumper public function __construct( private readonly string $direction = self::DIRECTION_TOP_TO_BOTTOM, ) { - if (!in_array($direction, self::VALID_DIRECTIONS, true)) { - throw new \InvalidArgumentException(sprintf( + if (!\in_array($direction, self::VALID_DIRECTIONS, true)) { + throw new \InvalidArgumentException(\sprintf( 'Direction "%s" is not valid, valid directions are: "%s".', $direction, implode('", "', self::VALID_DIRECTIONS) @@ -55,31 +55,26 @@ public function dump(RoleHierarchyInterface $roleHierarchy): string { $hierarchy = $this->extractHierarchy($roleHierarchy); - if ([] === $hierarchy) { + if (!$hierarchy) { return "graph {$this->direction}\n classDef default fill:#e1f5fe;"; } $output = ["graph {$this->direction}"]; $allRoles = $this->getAllRoles($hierarchy); - // Add role nodes foreach ($allRoles as $role) { $output[] = $this->formatRoleNode($role); } - // Add hierarchy relationships (parent -> child) foreach ($hierarchy as $parentRole => $childRoles) { foreach ($childRoles as $childRole) { - $output[] = " {$this->escapeRoleName($parentRole)} --> {$this->escapeRoleName($childRole)}"; + $output[] = " {$this->normalizeRoleName($parentRole)} --> {$this->normalizeRoleName($childRole)}"; } } return implode("\n", array_filter($output)); } - /** - * Extracts the role hierarchy from the RoleHierarchyInterface. - */ private function extractHierarchy(RoleHierarchyInterface $roleHierarchy): array { $reflection = new \ReflectionClass($roleHierarchy); @@ -92,9 +87,6 @@ private function extractHierarchy(RoleHierarchyInterface $roleHierarchy): array return []; } - /** - * Gets all unique roles from the hierarchy. - */ private function getAllRoles(array $hierarchy): array { $allRoles = []; @@ -109,21 +101,17 @@ private function getAllRoles(array $hierarchy): array return array_unique($allRoles); } - /** - * Formats a role node for Mermaid. - */ private function formatRoleNode(string $role): string { - $escapedRole = $this->escapeRoleName($role); + $escapedRole = $this->normalizeRoleName($role); return " {$escapedRole}"; } /** - * Escapes role name for use as Mermaid node ID. + * Normalizes the role name by replacing non-alphanumeric characters with underscores. */ - private function escapeRoleName(string $role): ?string + private function normalizeRoleName(string $role): ?string { - // Replace any non-alphanumeric characters with underscores 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 index 14f8519b90c62..f2da78c82242b 100644 --- a/src/Symfony/Component/Security/Core/Tests/Dumper/MermaidDumperTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Dumper/MermaidDumperTest.php @@ -73,7 +73,6 @@ public function testDumpComplexHierarchy() $dumper = new MermaidDumper(); $output = $dumper->dump($roleHierarchy); - // Check that all roles are present $this->assertStringContainsString('ROLE_SUPER_ADMIN', $output); $this->assertStringContainsString('ROLE_ADMIN', $output); $this->assertStringContainsString('ROLE_MANAGER', $output); @@ -81,7 +80,6 @@ public function testDumpComplexHierarchy() $this->assertStringContainsString('ROLE_USER', $output); $this->assertStringContainsString('ROLE_ALLOWED_TO_SWITCH', $output); - // Check relationships $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); @@ -97,20 +95,17 @@ public function testInvalidDirection() new MermaidDumper('INVALID'); } - public function testValidDirections() + /** + * @testWith ["TB"] + * ["TD"] + * ["BT"] + * ["RL"] + * ["LR"] + */ + public function testValidDirections(string $direction) { - $validDirections = [ - MermaidDumper::DIRECTION_TOP_TO_BOTTOM, - MermaidDumper::DIRECTION_TOP_DOWN, - MermaidDumper::DIRECTION_BOTTOM_TO_TOP, - MermaidDumper::DIRECTION_RIGHT_TO_LEFT, - MermaidDumper::DIRECTION_LEFT_TO_RIGHT, - ]; - - foreach ($validDirections as $direction) { - $dumper = new MermaidDumper($direction); - $this->assertInstanceOf(MermaidDumper::class, $dumper); - } + $this->expectNotToPerformAssertions(); + new MermaidDumper($direction); } public function testRoleNameEscaping() From cfc4e24eb9bd4d188c334b847c09d3b84afb0dcd Mon Sep 17 00:00:00 2001 From: Damien Fernandes Date: Tue, 8 Jul 2025 08:39:29 +0200 Subject: [PATCH 09/13] review: remove format option --- .../SecurityRoleHierarchyDumpCommand.php | 17 +++-------------- .../SecurityRoleHierarchyDumpCommandTest.php | 17 ----------------- 2 files changed, 3 insertions(+), 31 deletions(-) diff --git a/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php index 85425070191f5..21d5edecbcb9d 100644 --- a/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php +++ b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php @@ -39,11 +39,10 @@ protected function configure(): void { $this ->setDefinition([ - new InputOption('direction', 'd', InputOption::VALUE_REQUIRED, 'The direction of the flowchart ['.implode('|', MermaidDumper::VALID_DIRECTIONS).']', MermaidDumper::DIRECTION_TOP_TO_BOTTOM), - new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'The output format', 'mermaid'), + new InputOption('direction', 'd', InputOption::VALUE_REQUIRED, 'The direction of the flowchart ['.implode('|', MermaidDumper::VALID_DIRECTIONS).']', MermaidDumper::DIRECTION_TOP_TO_BOTTOM) ]) ->setHelp(<<<'USAGE' -The %command.name% command dumps the role hierarchy in different formats. +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 @@ -60,14 +59,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $direction = $input->getOption('direction'); - $format = $input->getOption('format'); - if ('mermaid' !== $format) { - $output->writeln('Only "mermaid" format is currently supported.'); - return Command::FAILURE; - } - - if (!in_array($direction, MermaidDumper::VALID_DIRECTIONS, true)) { + if (!\in_array($direction, MermaidDumper::VALID_DIRECTIONS, true)) { $output->writeln('Invalid direction. Available options: '.implode(', ', MermaidDumper::VALID_DIRECTIONS).''); return Command::FAILURE; } @@ -93,9 +86,5 @@ public function complete(CompletionInput $input, CompletionSuggestions $suggesti if ($input->mustSuggestOptionValuesFor('direction')) { $suggestions->suggestValues(MermaidDumper::VALID_DIRECTIONS); } - - if ($input->mustSuggestOptionValuesFor('format')) { - $suggestions->suggestValues(['mermaid']); - } } } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.php index 5b9840ea0a254..7f58bd7221517 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.php @@ -92,21 +92,4 @@ public function testExecuteWithInvalidDirection() $this->assertSame(Command::FAILURE, $exitCode); $this->assertStringContainsString('Invalid direction', $commandTester->getDisplay()); } - - public function testExecuteWithInvalidFormat() - { - $hierarchy = [ - 'ROLE_ADMIN' => ['ROLE_USER'], - ]; - - $roleHierarchy = new RoleHierarchy($hierarchy); - $command = new SecurityRoleHierarchyDumpCommand($roleHierarchy); - $commandTester = new CommandTester($command); - - $exitCode = $commandTester->execute(['--format' => 'dot']); - - $this->assertSame(Command::FAILURE, $exitCode); - $this->assertStringContainsString('Only "mermaid" format is currently supported', $commandTester->getDisplay()); - } - } From b21d28d529d9aaed55b65aacad79b53dfe77ad3e Mon Sep 17 00:00:00 2001 From: Damien Fernandes Date: Wed, 9 Jul 2025 09:04:41 +0200 Subject: [PATCH 10/13] review: use MermaidEnumDirection instead of string --- .../SecurityRoleHierarchyDumpCommand.php | 34 +++++++++++-------- .../Core/Dumper/MermaidDirectionEnum.php | 12 +++++++ .../Security/Core/Dumper/MermaidDumper.php | 27 ++------------- .../Core/Tests/Dumper/MermaidDumperTest.php | 25 +++++++++----- 4 files changed, 51 insertions(+), 47 deletions(-) create mode 100644 src/Symfony/Component/Security/Core/Dumper/MermaidDirectionEnum.php diff --git a/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php index 21d5edecbcb9d..078428fc70620 100644 --- a/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php +++ b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php @@ -18,6 +18,8 @@ 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; @@ -39,7 +41,7 @@ protected function configure(): void { $this ->setDefinition([ - new InputOption('direction', 'd', InputOption::VALUE_REQUIRED, 'The direction of the flowchart ['.implode('|', MermaidDumper::VALID_DIRECTIONS).']', MermaidDumper::DIRECTION_TOP_TO_BOTTOM) + new InputOption('direction', 'd', InputOption::VALUE_REQUIRED, 'The direction of the flowchart ['. implode('|', $this->getAvailableDirections()) .']', MermaidDirectionEnum::TOP_TO_BOTTOM->value) ]) ->setHelp(<<<'USAGE' The %command.name% command dumps the role hierarchy in Mermaid format. @@ -53,27 +55,21 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { + $io = new SymfonyStyle($input, $output); + if (null === $this->roleHierarchy) { - $output->writeln('No role hierarchy is configured.'); + $io->getErrorStyle()->writeln('No role hierarchy is configured.'); return Command::SUCCESS; } $direction = $input->getOption('direction'); - if (!\in_array($direction, MermaidDumper::VALID_DIRECTIONS, true)) { - $output->writeln('Invalid direction. Available options: '.implode(', ', MermaidDumper::VALID_DIRECTIONS).''); + if (! MermaidDirectionEnum::tryFrom($direction)) { + $io->getErrorStyle()->writeln('Invalid direction. Available options: '. implode('|', $this->getAvailableDirections()) .''); return Command::FAILURE; } - $directionMap = [ - 'TB' => MermaidDumper::DIRECTION_TOP_TO_BOTTOM, - 'TD' => MermaidDumper::DIRECTION_TOP_DOWN, - 'BT' => MermaidDumper::DIRECTION_BOTTOM_TO_TOP, - 'RL' => MermaidDumper::DIRECTION_RIGHT_TO_LEFT, - 'LR' => MermaidDumper::DIRECTION_LEFT_TO_RIGHT, - ]; - - $dumper = new MermaidDumper($directionMap[$direction]); + $dumper = new MermaidDumper(MermaidDirectionEnum::from($direction)); $mermaidOutput = $dumper->dump($this->roleHierarchy); $output->writeln($mermaidOutput); @@ -84,7 +80,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void { if ($input->mustSuggestOptionValuesFor('direction')) { - $suggestions->suggestValues(MermaidDumper::VALID_DIRECTIONS); + $suggestions->suggestValues( + $this->getAvailableDirections() + ); } } + + /** + * @return string[] + */ + private function getAvailableDirections(): array + { + return array_map(fn($case) => $case->value, MermaidDirectionEnum::cases()); + } } 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..45278b86375ff --- /dev/null +++ b/src/Symfony/Component/Security/Core/Dumper/MermaidDirectionEnum.php @@ -0,0 +1,12 @@ +extractHierarchy($roleHierarchy); if (!$hierarchy) { - return "graph {$this->direction}\n classDef default fill:#e1f5fe;"; + return "graph {$this->direction->value}\n classDef default fill:#e1f5fe;"; } - $output = ["graph {$this->direction}"]; + $output = ["graph {$this->direction->value}"]; $allRoles = $this->getAllRoles($hierarchy); foreach ($allRoles as $role) { diff --git a/src/Symfony/Component/Security/Core/Tests/Dumper/MermaidDumperTest.php b/src/Symfony/Component/Security/Core/Tests/Dumper/MermaidDumperTest.php index f2da78c82242b..81f6b27952a48 100644 --- a/src/Symfony/Component/Security/Core/Tests/Dumper/MermaidDumperTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Dumper/MermaidDumperTest.php @@ -12,6 +12,7 @@ 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; @@ -44,7 +45,7 @@ public function testDumpWithDirection() ]; $roleHierarchy = new RoleHierarchy($hierarchy); - $dumper = new MermaidDumper(MermaidDumper::DIRECTION_LEFT_TO_RIGHT); + $dumper = new MermaidDumper(MermaidDirectionEnum::LEFT_TO_RIGHT); $output = $dumper->dump($roleHierarchy); $this->assertStringContainsString('graph LR', $output); @@ -89,25 +90,31 @@ public function testDumpComplexHierarchy() public function testInvalidDirection() { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Direction "INVALID" is not valid'); + $this->expectException(\TypeError::class); new MermaidDumper('INVALID'); } /** - * @testWith ["TB"] - * ["TD"] - * ["BT"] - * ["RL"] - * ["LR"] + * @dataProvider dataProviderValidDirection */ - public function testValidDirections(string $direction) + public function testValidDirections(MermaidDirectionEnum $direction) { $this->expectNotToPerformAssertions(); new MermaidDumper($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 = [ From 0b86dfad1814db318495e52aed35b04027e0d0b3 Mon Sep 17 00:00:00 2001 From: Damien Fernandes Date: Fri, 11 Jul 2025 12:00:43 +0200 Subject: [PATCH 11/13] review: used named argument for command and add suggestedValues in definition --- .../SecurityRoleHierarchyDumpCommand.php | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php index 078428fc70620..377daace0c5bb 100644 --- a/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php +++ b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php @@ -41,7 +41,14 @@ protected function configure(): void { $this ->setDefinition([ - new InputOption('direction', 'd', InputOption::VALUE_REQUIRED, 'The direction of the flowchart ['. implode('|', $this->getAvailableDirections()) .']', MermaidDirectionEnum::TOP_TO_BOTTOM->value) + 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. @@ -77,15 +84,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } - public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void - { - if ($input->mustSuggestOptionValuesFor('direction')) { - $suggestions->suggestValues( - $this->getAvailableDirections() - ); - } - } - /** * @return string[] */ From 4ff1b49c8bda9c2c4fa2dae688b7f98a413793d5 Mon Sep 17 00:00:00 2001 From: Damien Fernandes Date: Wed, 16 Jul 2025 07:57:29 +0200 Subject: [PATCH 12/13] review: check RoleHierarchy class on reflexion when extracting roles --- .../Command/SecurityRoleHierarchyDumpCommand.php | 14 +++++++------- .../SecurityRoleHierarchyDumpCommandTest.php | 1 - .../Security/Core/Dumper/MermaidDirectionEnum.php | 9 +++++++++ .../Security/Core/Dumper/MermaidDumper.php | 9 ++++++++- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php index 377daace0c5bb..b2a3f20733a07 100644 --- a/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php +++ b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php @@ -13,8 +13,6 @@ use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Completion\CompletionInput; -use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -45,10 +43,10 @@ protected function configure(): void name: 'direction', shortcut: 'd', mode: InputOption::VALUE_REQUIRED, - description: 'The direction of the flowchart ['. implode('|', $this->getAvailableDirections()) .']', + 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. @@ -66,13 +64,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int 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()) .''); + if (!MermaidDirectionEnum::tryFrom($direction)) { + $io->getErrorStyle()->writeln('Invalid direction. Available options: '.implode('|', $this->getAvailableDirections()).''); + return Command::FAILURE; } @@ -89,6 +89,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int */ private function getAvailableDirections(): array { - return array_map(fn($case) => $case->value, MermaidDirectionEnum::cases()); + return array_map(fn ($case) => $case->value, MermaidDirectionEnum::cases()); } } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.php index 7f58bd7221517..a6d27fe63142d 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.php @@ -13,7 +13,6 @@ use PHPUnit\Framework\TestCase; use Symfony\Bundle\SecurityBundle\Command\SecurityRoleHierarchyDumpCommand; -use Symfony\Component\Console\Application; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Security\Core\Role\RoleHierarchy; diff --git a/src/Symfony/Component/Security/Core/Dumper/MermaidDirectionEnum.php b/src/Symfony/Component/Security/Core/Dumper/MermaidDirectionEnum.php index 45278b86375ff..b94af8701d1db 100644 --- a/src/Symfony/Component/Security/Core/Dumper/MermaidDirectionEnum.php +++ b/src/Symfony/Component/Security/Core/Dumper/MermaidDirectionEnum.php @@ -1,5 +1,14 @@ + * + * 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 diff --git a/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php b/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php index 07940ea4b343f..76119351a8146 100644 --- a/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php +++ b/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Security\Core\Dumper; +use Symfony\Component\Security\Core\Role\RoleHierarchy; use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; /** @@ -56,10 +57,15 @@ public function dump(RoleHierarchyInterface $roleHierarchy): string private function extractHierarchy(RoleHierarchyInterface $roleHierarchy): array { - $reflection = new \ReflectionClass($roleHierarchy); + if (!$roleHierarchy instanceof RoleHierarchy) { + return []; + } + + $reflection = new \ReflectionClass(RoleHierarchy::class); if ($reflection->hasProperty('hierarchy')) { $hierarchyProperty = $reflection->getProperty('hierarchy'); + return $hierarchyProperty->getValue($roleHierarchy); } @@ -83,6 +89,7 @@ private function getAllRoles(array $hierarchy): array private function formatRoleNode(string $role): string { $escapedRole = $this->normalizeRoleName($role); + return " {$escapedRole}"; } From 96d1fdaeae5dcbfcb8f654bc3ceeeefdf6b4b499 Mon Sep 17 00:00:00 2001 From: Damien Fernandes Date: Fri, 18 Jul 2025 18:00:11 +0200 Subject: [PATCH 13/13] refactor: update MermaidDumper to accept direction as a parameter in dump method --- .../Command/SecurityRoleHierarchyDumpCommand.php | 4 ++-- .../Component/Security/Core/Dumper/MermaidDumper.php | 12 ++++-------- .../Security/Core/Tests/Dumper/MermaidDumperTest.php | 10 ++++++---- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php index b2a3f20733a07..7072c38246bf4 100644 --- a/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php +++ b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php @@ -76,8 +76,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - $dumper = new MermaidDumper(MermaidDirectionEnum::from($direction)); - $mermaidOutput = $dumper->dump($this->roleHierarchy); + $dumper = new MermaidDumper(); + $mermaidOutput = $dumper->dump($this->roleHierarchy, MermaidDirectionEnum::from($direction)); $output->writeln($mermaidOutput); diff --git a/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php b/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php index 76119351a8146..ab1beac6e53f6 100644 --- a/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php +++ b/src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php @@ -21,25 +21,21 @@ */ class MermaidDumper { - public function __construct( - private readonly MermaidDirectionEnum $direction = MermaidDirectionEnum::TOP_TO_BOTTOM, - ) { - } - /** * 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): string + public function dump(RoleHierarchyInterface $roleHierarchy, MermaidDirectionEnum $direction = MermaidDirectionEnum::TOP_TO_BOTTOM): string { $hierarchy = $this->extractHierarchy($roleHierarchy); if (!$hierarchy) { - return "graph {$this->direction->value}\n classDef default fill:#e1f5fe;"; + return "graph {$direction->value}\n classDef default fill:#e1f5fe;"; } - $output = ["graph {$this->direction->value}"]; + $output = ["graph {$direction->value}"]; $allRoles = $this->getAllRoles($hierarchy); foreach ($allRoles as $role) { diff --git a/src/Symfony/Component/Security/Core/Tests/Dumper/MermaidDumperTest.php b/src/Symfony/Component/Security/Core/Tests/Dumper/MermaidDumperTest.php index 81f6b27952a48..5f3209cdd2e1a 100644 --- a/src/Symfony/Component/Security/Core/Tests/Dumper/MermaidDumperTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Dumper/MermaidDumperTest.php @@ -45,8 +45,8 @@ public function testDumpWithDirection() ]; $roleHierarchy = new RoleHierarchy($hierarchy); - $dumper = new MermaidDumper(MermaidDirectionEnum::LEFT_TO_RIGHT); - $output = $dumper->dump($roleHierarchy); + $dumper = new MermaidDumper(); + $output = $dumper->dump($roleHierarchy, MermaidDirectionEnum::LEFT_TO_RIGHT); $this->assertStringContainsString('graph LR', $output); } @@ -92,7 +92,8 @@ public function testInvalidDirection() { $this->expectException(\TypeError::class); - new MermaidDumper('INVALID'); + $dumper = new MermaidDumper(); + $dumper->dump(new RoleHierarchy([]), 'INVALID'); } /** @@ -101,7 +102,8 @@ public function testInvalidDirection() public function testValidDirections(MermaidDirectionEnum $direction) { $this->expectNotToPerformAssertions(); - new MermaidDumper($direction); + $dumper = new MermaidDumper(); + $dumper->dump(new RoleHierarchy([]), $direction); } public static function dataProviderValidDirection() 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