Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Symfony/Bundle/SecurityBundle/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ CHANGELOG
7.4
---

* Add `debug:security:role-hierarchy` command to dump role hierarchy graphs in the Mermaid.js flowchart format
* Add `Security::getAccessDecision()` and `getAccessDecisionForUser()` helpers
* Add options to configure a cache pool and storage service for login throttling rate limiters
* Register alias for argument for password hasher when its key is not a class name:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Bundle\SecurityBundle\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Security\Core\Dumper\MermaidDirectionEnum;
use Symfony\Component\Security\Core\Dumper\MermaidDumper;
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;

/**
* Command to dump the role hierarchy as a Mermaid flowchart.
*
* @author Damien Fernandes <damien.fernandes24@gmail.com>
*/
#[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,
) {
parent::__construct();
}

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,
$this->getAvailableDirections()
),
])
->setHelp(<<<'USAGE'
The <info>%command.name%</info> command dumps the role hierarchy in Mermaid format.

<info>Mermaid</info>: %command.full_name% > roles.mmd
<info>Mermaid with direction</info>: %command.full_name% --direction=BT > roles.mmd
USAGE
)
;
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);

if (null === $this->roleHierarchy) {
$io->getErrorStyle()->writeln('<comment>No role hierarchy is configured.</comment>');

return Command::SUCCESS;
}

$direction = $input->getOption('direction');

if (!MermaidDirectionEnum::tryFrom($direction)) {
$io->getErrorStyle()->writeln(\sprintf('<error>Invalid direction, available options are "%s"</error>', implode('"', $this->getAvailableDirections())));

return Command::FAILURE;
}

$dumper = new MermaidDumper();
$mermaidOutput = $dumper->dump($this->roleHierarchy, MermaidDirectionEnum::from($direction));

$output->writeln($mermaidOutput, OutputInterface::OUTPUT_RAW);

return Command::SUCCESS;
}

/**
* @return string[]
*/
private function getAvailableDirections(): array
{
return array_map(fn ($case) => $case->value, MermaidDirectionEnum::cases());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ public function load(array $configs, ContainerBuilder $container): void
$container->getDefinition('security.command.user_password_hash')->replaceArgument(1, array_keys($config['password_hashers']));
}

if ($container->hasDefinition('security.role_hierarchy')) {
$loader->load('security_role_hierarchy_dump_command.php');
}

$container->registerForAutoconfiguration(VoterInterface::class)
->addTag('security.voter');
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\DependencyInjection\Loader\Configurator;

use Symfony\Bundle\SecurityBundle\Command\SecurityRoleHierarchyDumpCommand;

return static function (ContainerConfigurator $container): void {
$container->services()
->set('security.command.role_hierarchy_dump', SecurityRoleHierarchyDumpCommand::class)
->args([
service('security.role_hierarchy'),
])
->tag('console.command')
;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Bundle\SecurityBundle\Tests\Command;

use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Command\SecurityRoleHierarchyDumpCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Security\Core\Role\RoleHierarchy;

class SecurityRoleHierarchyDumpCommandTest extends TestCase
{
public function testExecuteWithRoleHierarchy()
{
$hierarchy = [
'ROLE_ADMIN' => ['ROLE_USER'],
'ROLE_SUPER_ADMIN' => ['ROLE_ADMIN', 'ROLE_USER'],
];

$roleHierarchy = new RoleHierarchy($hierarchy);
$command = new SecurityRoleHierarchyDumpCommand($roleHierarchy);
$commandTester = new CommandTester($command);

$exitCode = $commandTester->execute([]);

$this->assertSame(Command::SUCCESS, $exitCode);
$output = $commandTester->getDisplay();
$expectedOutput = <<<EXPECTED
graph TB
ROLE_ADMIN
ROLE_USER
ROLE_SUPER_ADMIN
ROLE_ADMIN --> ROLE_USER
ROLE_SUPER_ADMIN --> ROLE_ADMIN
ROLE_SUPER_ADMIN --> ROLE_USER

EXPECTED;

$this->assertSame($expectedOutput, $output);
}

public function testExecuteWithCustomDirection()
{
$hierarchy = [
'ROLE_ADMIN' => ['ROLE_USER'],
];

$roleHierarchy = new RoleHierarchy($hierarchy);
$command = new SecurityRoleHierarchyDumpCommand($roleHierarchy);
$commandTester = new CommandTester($command);

$exitCode = $commandTester->execute(['--direction' => 'BT']);

$this->assertSame(Command::SUCCESS, $exitCode);
$output = $commandTester->getDisplay();
$this->assertStringContainsString('graph BT', $output);
}

public function testExecuteWithInvalidDirection()
{
$hierarchy = [
'ROLE_ADMIN' => ['ROLE_USER'],
];

$roleHierarchy = new RoleHierarchy($hierarchy);
$command = new SecurityRoleHierarchyDumpCommand($roleHierarchy);
$commandTester = new CommandTester($command);

$exitCode = $commandTester->execute(['--direction' => 'INVALID']);

$this->assertSame(Command::FAILURE, $exitCode);
$this->assertStringContainsString('Invalid direction', $commandTester->getDisplay());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,24 @@ public function testSwitchUserNotStatelessOnStatelessFirewall()
$this->assertTrue($container->getDefinition('security.authentication.switchuser_listener.some_firewall')->getArgument(9));
}

public function testRoleHierarchyDumpCommandIsRegisteredWithRoleHierarchy()
{
$container = $this->getRawContainer();
$container->loadFromExtension('security', [
'role_hierarchy' => [
'ROLE_ADMIN' => ['ROLE_USER'],
'ROLE_SUPER_ADMIN' => ['ROLE_ADMIN', 'ROLE_ALLOWED_TO_SWITCH'],
],
'firewalls' => [
'some_firewall' => [
],
],
]);
$container->compile();

$this->assertTrue($container->hasDefinition('security.command.role_hierarchy_dump'));
}

public function testPerListenerProvider()
{
$container = $this->getRawContainer();
Expand Down
2 changes: 1 addition & 1 deletion src/Symfony/Bundle/SecurityBundle/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"symfony/http-kernel": "^6.4.13|^7.1.6|^8.0",
"symfony/http-foundation": "^6.4|^7.0|^8.0",
"symfony/password-hasher": "^6.4|^7.0|^8.0",
"symfony/security-core": "^7.3|^8.0",
"symfony/security-core": "^7.4|^8.0",
"symfony/security-csrf": "^6.4|^7.0|^8.0",
"symfony/security-http": "^7.3|^8.0",
"symfony/service-contracts": "^2.5|^3"
Expand Down
5 changes: 5 additions & 0 deletions src/Symfony/Component/Security/Core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
CHANGELOG
=========

7.4
---

* Add `MermaidDumper` to dump Role Hierarchy graphs in the Mermaid.js flowchart format

7.3
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Security\Core\Dumper;

enum MermaidDirectionEnum: string
{
case TOP_TO_BOTTOM = 'TB';
case TOP_DOWN = 'TD';
case BOTTOM_TO_TOP = 'BT';
case RIGHT_TO_LEFT = 'RL';
case LEFT_TO_RIGHT = 'LR';
}
95 changes: 95 additions & 0 deletions src/Symfony/Component/Security/Core/Dumper/MermaidDumper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Security\Core\Dumper;

use Symfony\Component\Security\Core\Role\RoleHierarchy;
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;

/**
* MermaidDumper dumps a Mermaid flowchart describing role hierarchy.
*
* @author Damien Fernandes <damien.fernandes24@gmail.com>
*/
class MermaidDumper
{
/**
* Dumps the role hierarchy as a Mermaid flowchart.
*
* @param RoleHierarchyInterface $roleHierarchy The role hierarchy to dump
* @param MermaidDirectionEnum $direction The direction of the flowchart
*/
public function dump(RoleHierarchyInterface $roleHierarchy, MermaidDirectionEnum $direction = MermaidDirectionEnum::TOP_TO_BOTTOM): string
{
$hierarchy = $this->extractHierarchy($roleHierarchy);

if (!$hierarchy) {
return "graph {$direction->value}\n classDef default fill:#e1f5fe;";
}

$output = ["graph {$direction->value}"];
$allRoles = $this->getAllRoles($hierarchy);

foreach ($allRoles as $role) {
$output[] = $this->formatRoleNode($role);
}

foreach ($hierarchy as $parentRole => $childRoles) {
foreach ($childRoles as $childRole) {
$output[] = " {$this->normalizeRoleName($parentRole)} --> {$this->normalizeRoleName($childRole)}";
}
}

return implode("\n", array_filter($output));
}

private function extractHierarchy(RoleHierarchyInterface $roleHierarchy): array
{
if (!$roleHierarchy instanceof RoleHierarchy) {
return [];
}

$reflection = new \ReflectionClass(RoleHierarchy::class);

$hierarchyProperty = $reflection->getProperty('hierarchy');

return $hierarchyProperty->getValue($roleHierarchy);
}

private function getAllRoles(array $hierarchy): array
{
$allRoles = [];

foreach ($hierarchy as $parentRole => $childRoles) {
$allRoles[] = $parentRole;
foreach ($childRoles as $childRole) {
$allRoles[] = $childRole;
}
}

return array_unique($allRoles);
}

private function formatRoleNode(string $role): string
{
$escapedRole = $this->normalizeRoleName($role);

return " {$escapedRole}";
}

/**
* Normalizes the role name by replacing non-alphanumeric characters with underscores.
*/
private function normalizeRoleName(string $role): ?string
{
return preg_replace('/[^a-zA-Z0-9_]/', '_', $role);
}
}
Loading
Loading