Skip to content

Commit 58e48ce

Browse files
committed
Add a debug:roles command
1 parent db85067 commit 58e48ce

File tree

9 files changed

+619
-9
lines changed

9 files changed

+619
-9
lines changed

src/Symfony/Bundle/SecurityBundle/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ CHANGELOG
1515
6.4
1616
---
1717

18+
* Add the `debug:roles` command to debug role hierarchy
1819
* Deprecate `Security::ACCESS_DENIED_ERROR`, `AUTHENTICATION_ERROR` and `LAST_USERNAME` constants, use the ones on `SecurityRequestAttributes` instead
1920
* Allow an array of `pattern` in firewall configuration
2021
* Add `$badges` argument to `Security::login`
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bundle\SecurityBundle\Command;
13+
14+
use Symfony\Bundle\SecurityBundle\Debug\DebugRoleHierarchy;
15+
use Symfony\Component\Console\Attribute\AsCommand;
16+
use Symfony\Component\Console\Command\Command;
17+
use Symfony\Component\Console\Input\InputArgument;
18+
use Symfony\Component\Console\Input\InputInterface;
19+
use Symfony\Component\Console\Input\InputOption;
20+
use Symfony\Component\Console\Output\OutputInterface;
21+
use Symfony\Component\Console\Style\SymfonyStyle;
22+
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
23+
24+
#[AsCommand(name: 'debug:roles', description: 'Debug the role hierarchy configuration.')]
25+
final class DebugRolesCommand extends Command
26+
{
27+
public function __construct(private readonly RoleHierarchyInterface $roleHierarchy)
28+
{
29+
parent::__construct();
30+
}
31+
32+
protected function configure(): void
33+
{
34+
$this->setHelp(<<<EOF
35+
This <info>%command.name%</info> command display the current role hierarchy:
36+
37+
<info>php %command.full_name%</info>
38+
39+
You can pass one or multiple role names to display the effective roles:
40+
41+
<info>php %command.full_name% ROLE_USER</info>
42+
43+
To get a tree view of the inheritance, use the <info>tree</info> option:
44+
45+
<info>php %command.full_name% --tree</info>
46+
<info>php %command.full_name% ROLE_USER --tree</info>
47+
48+
<comment>Note:</comment> With a custom implementation for <info>security.role_hierarchy</info>, the <info>--tree</info> option is ignored and the <info>roles</info> argument is required.
49+
50+
EOF
51+
)
52+
->setDefinition([
53+
new InputArgument('roles', ($this->isBuiltInRoleHierarchy() ? InputArgument::OPTIONAL : InputArgument::REQUIRED) | InputArgument::IS_ARRAY, 'The role(s) to resolve'),
54+
new InputOption('tree', 't', InputOption::VALUE_NONE, 'Show the hierarchy in a tree view'),
55+
]);
56+
}
57+
58+
protected function initialize(InputInterface $input, OutputInterface $output): void
59+
{
60+
if (!$this->isBuiltInRoleHierarchy()) {
61+
$io = new SymfonyStyle($input, $output);
62+
63+
if ($input->getOption('tree')) {
64+
$io->warning('Ignoring option "--tree" because of a custom role hierarchy implementation.');
65+
$input->setOption('tree', null);
66+
}
67+
}
68+
}
69+
70+
protected function interact(InputInterface $input, OutputInterface $output): void
71+
{
72+
if (!$this->isBuiltInRoleHierarchy() && empty($input->getArgument('roles'))) {
73+
$io = new SymfonyStyle($input, $output);
74+
75+
$roles[] = $io->ask('Enter a role to debug', validator: function (?string $role) {
76+
$role = trim($role);
77+
if (empty($role)) {
78+
throw new \RuntimeException('You must enter a non empty role name.');
79+
}
80+
81+
return $role;
82+
});
83+
while ($role = trim($io->ask('Add another role? (press enter to skip)') ?? '')) {
84+
$roles[] = $role;
85+
}
86+
87+
$input->setArgument('roles', $roles);
88+
}
89+
}
90+
91+
protected function execute(InputInterface $input, OutputInterface $output): int
92+
{
93+
$io = new SymfonyStyle($input, $output);
94+
95+
$roles = $input->getArgument('roles');
96+
97+
if (empty($roles)) {
98+
// Full configuration output
99+
$io->title('Current role hierarchy configuration:');
100+
101+
if ($input->getOption('tree')) {
102+
$this->outputTree($io, $this->getBuiltInDebugHierarchy()->getHierarchy());
103+
} else {
104+
$this->outputMap($io, $this->getBuiltInDebugHierarchy()->getMap());
105+
}
106+
107+
$io->comment('To show reachable roles for a given role, re-run this command with role names. (e.g. <comment>debug:roles ROLE_USER</comment>)');
108+
109+
return self::SUCCESS;
110+
}
111+
112+
// Matching roles output
113+
$io->title(sprintf('Effective roles for %s:', implode(', ', array_map(fn ($v) => sprintf('<info>%s</info>', $v), $roles))));
114+
115+
if ($input->getOption('tree')) {
116+
$this->outputTree($io, $this->getBuiltInDebugHierarchy()->getHierarchy($roles));
117+
} else {
118+
$io->listing($this->roleHierarchy->getReachableRoleNames($roles));
119+
}
120+
121+
return self::SUCCESS;
122+
}
123+
124+
private function outputMap(OutputInterface $output, array $map): void
125+
{
126+
foreach ($map as $main => $roles) {
127+
if ($this->getBuiltInDebugHierarchy()->isPlaceholder($main)) {
128+
$main = $this->stylePlaceholder($main);
129+
}
130+
131+
$output->writeln(sprintf('%s:', $main));
132+
foreach ($roles as $r) {
133+
$output->writeln(sprintf(' - %s', $r));
134+
}
135+
$output->writeln('');
136+
}
137+
}
138+
139+
private function outputTree(OutputInterface $output, array $tree): void
140+
{
141+
foreach ($tree as $role => $hierarchy) {
142+
$output->writeln($this->generateTree($role, $hierarchy));
143+
$output->writeln('');
144+
}
145+
}
146+
147+
/**
148+
* Generates a tree representation, line by line, in the tree unix style.
149+
*
150+
* Example output:
151+
*
152+
* ROLE_A
153+
* └── ROLE_B
154+
*
155+
* ROLE_C
156+
* ├── ROLE_A
157+
* │ └── ROLE_B
158+
* └── ROLE_D
159+
*/
160+
private function generateTree(string $name, array $tree, string $indent = '', bool $last = true, bool $root = true): \Generator
161+
{
162+
if ($this->getBuiltInDebugHierarchy()->isPlaceholder($name)) {
163+
$name = $this->stylePlaceholder($name);
164+
}
165+
166+
if ($root) {
167+
// Yield root node as it is
168+
yield $name;
169+
} else {
170+
// Generate line in the tree:
171+
// Line: [indent]├── [name]
172+
// Last line: [indent]└── [name]
173+
yield sprintf('%s%s%s %s', $indent, $last ? "\u{2514}" : "\u{251c}", str_repeat("\u{2500}", 2), $name);
174+
175+
// Update indent for next nested:
176+
// Append "| " for a nested tree
177+
// Append " " for last nested tree
178+
$indent .= ($last ? ' ' : "\u{2502}").str_repeat(' ', 3);
179+
}
180+
181+
$i = 0;
182+
$count = \count($tree);
183+
foreach ($tree as $key => $value) {
184+
yield from $this->generateTree($key, $value, $indent, $i === $count - 1, false);
185+
++$i;
186+
}
187+
}
188+
189+
private function stylePlaceholder(string $role): string
190+
{
191+
return sprintf('<info>%s</info>', $role);
192+
}
193+
194+
private function isBuiltInRoleHierarchy(): bool
195+
{
196+
return $this->roleHierarchy instanceof DebugRoleHierarchy;
197+
}
198+
199+
private function getBuiltInDebugHierarchy(): DebugRoleHierarchy
200+
{
201+
if (!$this->roleHierarchy instanceof DebugRoleHierarchy) {
202+
throw new \LogicException('Cannot use the built-in debug hierarchy with a custom implementation.');
203+
}
204+
205+
return $this->roleHierarchy;
206+
}
207+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bundle\SecurityBundle\Debug;
13+
14+
use Symfony\Component\Security\Core\Role\RoleHierarchy;
15+
16+
/**
17+
* Extended Role Hierarchy to access inner configuration data.
18+
*
19+
* @author Nicolas Rigaud <squrious@protonmail.com>
20+
*
21+
* @internal
22+
*/
23+
final class DebugRoleHierarchy extends RoleHierarchy
24+
{
25+
private readonly array $debugHierarchy;
26+
27+
public function __construct(array $hierarchy)
28+
{
29+
$this->debugHierarchy = $hierarchy;
30+
31+
parent::__construct($hierarchy);
32+
}
33+
34+
/**
35+
* Get the hierarchy tree.
36+
*
37+
* Example output:
38+
*
39+
* [
40+
* 'ROLE_A' => [
41+
* 'ROLE_B' => [],
42+
* 'ROLE_C' => [
43+
* 'ROLE_D' => []
44+
* ]
45+
* ],
46+
* 'ROLE_C' => [
47+
* 'ROLE_D' => []
48+
* ]
49+
* ]
50+
*
51+
* @param string[] $roles Optionally restrict the tree to these roles
52+
*
53+
* @return array<string,array<string,array>>
54+
*/
55+
public function getHierarchy(array $roles = []): array
56+
{
57+
$hierarchy = [];
58+
59+
foreach ($roles ?: array_keys($this->debugHierarchy) as $role) {
60+
$hierarchy += $this->buildHierarchy([$role]);
61+
}
62+
63+
return $hierarchy;
64+
}
65+
66+
/**
67+
* Get the computed role map.
68+
*
69+
* @return array<string,string[]>
70+
*/
71+
public function getMap(): array
72+
{
73+
return $this->map;
74+
}
75+
76+
/**
77+
* Return whether a given role is processed as a placeholder.
78+
*/
79+
public function isPlaceholder(string $role): bool
80+
{
81+
return \in_array($role, array_keys($this->rolePlaceholdersPatterns));
82+
}
83+
84+
private function buildHierarchy(array $roles, array &$visited = []): array
85+
{
86+
$tree = [];
87+
foreach ($roles as $role) {
88+
$visited[] = $role;
89+
90+
$tree[$role] = [];
91+
92+
// Get placeholders matches
93+
$placeholders = array_diff($this->getMatchingPlaceholders([$role]), $visited) ?? [];
94+
array_push($visited, ...$placeholders);
95+
$tree[$role] += $this->buildHierarchy($placeholders, $visited);
96+
97+
// Get regular inherited roles
98+
$inherited = array_diff($this->debugHierarchy[$role] ?? [], $visited);
99+
array_push($visited, ...$inherited);
100+
$tree[$role] += $this->buildHierarchy($inherited, $visited);
101+
}
102+
103+
return $tree;
104+
}
105+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler;
13+
14+
use Symfony\Bundle\SecurityBundle\Debug\DebugRoleHierarchy;
15+
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
16+
use Symfony\Component\DependencyInjection\ContainerBuilder;
17+
use Symfony\Component\DependencyInjection\Definition;
18+
use Symfony\Component\Security\Core\Role\RoleHierarchy;
19+
20+
class RegisterDebugRoleHierarchyPass implements CompilerPassInterface
21+
{
22+
public function process(ContainerBuilder $container): void
23+
{
24+
if (!$container->hasDefinition('security.role_hierarchy')) {
25+
$container->removeDefinition('security.command.debug_role_hierarchy');
26+
27+
return;
28+
}
29+
30+
$definition = $container->findDefinition('security.role_hierarchy');
31+
32+
if (RoleHierarchy::class === $definition->getClass()) {
33+
$hierarchy = $definition->getArgument(0);
34+
$definition = new Definition(DebugRoleHierarchy::class, [$hierarchy]);
35+
}
36+
$container->setDefinition('debug.security.role_hierarchy', $definition);
37+
}
38+
}

src/Symfony/Bundle/SecurityBundle/Resources/config/debug_console.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
1313

1414
use Symfony\Bundle\SecurityBundle\Command\DebugFirewallCommand;
15+
use Symfony\Bundle\SecurityBundle\Command\DebugRolesCommand;
1516

1617
return static function (ContainerConfigurator $container) {
1718
$container->services()
@@ -24,5 +25,10 @@
2425
false,
2526
])
2627
->tag('console.command', ['command' => 'debug:firewall'])
28+
->set('security.command.debug_role_hierarchy', DebugRolesCommand::class)
29+
->args([
30+
service('debug.security.role_hierarchy'),
31+
])
32+
->tag('console.command', ['command' => 'debug:roles'])
2733
;
2834
};

src/Symfony/Bundle/SecurityBundle/SecurityBundle.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\CleanRememberMeVerifierPass;
1818
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\MakeFirewallsEventDispatcherTraceablePass;
1919
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterCsrfFeaturesPass;
20+
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterDebugRoleHierarchyPass;
2021
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterEntryPointPass;
2122
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterGlobalSecurityEventListenersPass;
2223
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterLdapLocatorPass;
@@ -103,5 +104,7 @@ public function build(ContainerBuilder $container): void
103104

104105
// must be registered before DecoratorServicePass
105106
$container->addCompilerPass(new MakeFirewallsEventDispatcherTraceablePass(), PassConfig::TYPE_OPTIMIZE, 10);
107+
108+
$container->addCompilerPass(new RegisterDebugRoleHierarchyPass(), PassConfig::TYPE_OPTIMIZE);
106109
}
107110
}

0 commit comments

Comments
 (0)