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..2c2521ef987f6 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Command/SecurityRoleHierarchyDumpCommand.php @@ -0,0 +1,102 @@ + + * + * 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 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('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' +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, MermaidDumper::VALID_DIRECTIONS, true)) { + $output->writeln('Invalid direction. Available options: '.implode(', ', MermaidDumper::VALID_DIRECTIONS).''); + 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(MermaidDumper::VALID_DIRECTIONS); + } + + 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..87244bbe06a66 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Command/SecurityRoleHierarchyDumpCommandTest.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\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(); + $expectedOutput = << ROLE_USER + ROLE_SUPER_ADMIN --> ROLE_ADMIN + ROLE_SUPER_ADMIN --> ROLE_USER + +EXPECTED; + + $this->assertEquals($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->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/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 --- 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..f3eb293fbb0c2 --- /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'; + + public 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); + } +}