From 02f276192b46be23c533d628a762f7252ce2c73b Mon Sep 17 00:00:00 2001 From: Damien Fernandes Date: Fri, 4 Jul 2025 08:44:50 +0200 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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