From e34f0b484d304bb0ec1de87e668c15d13cbc48dd Mon Sep 17 00:00:00 2001 From: Nicolas Rigaud Date: Mon, 16 Oct 2023 16:03:29 +0200 Subject: [PATCH 1/5] [Security] Handle placeholders in role hierarchy --- .../Component/Security/Core/CHANGELOG.md | 1 + .../Security/Core/Role/RoleHierarchy.php | 66 +++++++++++++++---- .../Voter/RoleHierarchyVoterTest.php | 9 ++- .../Core/Tests/Role/RoleHierarchyTest.php | 40 +++++++++++ 4 files changed, 102 insertions(+), 14 deletions(-) diff --git a/src/Symfony/Component/Security/Core/CHANGELOG.md b/src/Symfony/Component/Security/Core/CHANGELOG.md index 47b4a21082738..9452408aba321 100644 --- a/src/Symfony/Component/Security/Core/CHANGELOG.md +++ b/src/Symfony/Component/Security/Core/CHANGELOG.md @@ -12,6 +12,7 @@ CHANGELOG 6.4 --- + * Allow using wildcards as placeholders in `RoleHierarchy` map's keys * Make `PersistentToken` immutable * Deprecate accepting only `DateTime` for `TokenProviderInterface::updateToken()`, use `DateTimeInterface` instead diff --git a/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php b/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php index 15c5750d88c62..60302696d9556 100644 --- a/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php +++ b/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php @@ -18,6 +18,13 @@ */ class RoleHierarchy implements RoleHierarchyInterface { + /** + * Map role placeholders with their regex pattern. + * + * @var array + */ + private array $rolePlaceholdersPatterns; + /** @var array> */ protected array $map; @@ -35,24 +42,14 @@ public function __construct(array $hierarchy) public function getReachableRoleNames(array $roles): array { - $reachableRoles = $roles; - - foreach ($roles as $role) { - if (!isset($this->map[$role])) { - continue; - } - - foreach ($this->map[$role] as $r) { - $reachableRoles[] = $r; - } - } - - return array_values(array_unique($reachableRoles)); + return \array_values(\array_unique($this->resolveReachableRoleNames($roles))); } protected function buildRoleMap(): void { $this->map = []; + $this->rolePlaceholdersPatterns = []; + foreach ($this->hierarchy as $main => $roles) { $this->map[$main] = $roles; $visited = []; @@ -74,6 +71,49 @@ protected function buildRoleMap(): void } $this->map[$main] = array_unique($this->map[$main]); + + if (str_contains($main, '*')) { + $this->rolePlaceholdersPatterns[$main] = sprintf('/%s/', strtr($main, ['*' => '[^\*]+'])); + } + } + } + + private function resolveReachableRoleNames(array $roles, array &$visitedPlaceholders = []): array + { + $reachableRoles = $roles; + + foreach ($roles as $role) { + if (!isset($this->map[$role])) { + continue; + } + + foreach ($this->map[$role] as $r) { + $reachableRoles[] = $r; + } + } + + $placeholderRoles = array_diff($this->getMatchingPlaceholders($reachableRoles), $visitedPlaceholders); + if (!empty($placeholderRoles)) { + array_push($visitedPlaceholders, ...$placeholderRoles); + $resolvedPlaceholderRoles = $this->resolveReachableRoleNames($placeholderRoles, $visitedPlaceholders); + foreach (array_diff($resolvedPlaceholderRoles, $placeholderRoles) as $r) { + $reachableRoles[] = $r; + } } + + return $reachableRoles; + } + + private function getMatchingPlaceholders(array $roles): array + { + $resolved = []; + + foreach ($this->rolePlaceholdersPatterns as $placeholder => $pattern) { + if (!\in_array($placeholder, $resolved) && \count(preg_grep($pattern, $roles) ?? null)) { + $resolved[] = $placeholder; + } + } + + return $resolved; } } diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleHierarchyVoterTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleHierarchyVoterTest.php index b811bd745bb85..89cdcdc031304 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleHierarchyVoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleHierarchyVoterTest.php @@ -22,7 +22,11 @@ class RoleHierarchyVoterTest extends RoleVoterTest */ public function testVoteUsingTokenThatReturnsRoleNames($roles, $attributes, $expected) { - $voter = new RoleHierarchyVoter(new RoleHierarchy(['ROLE_FOO' => ['ROLE_FOOBAR']])); + $voter = new RoleHierarchyVoter(new RoleHierarchy([ + 'ROLE_FOO' => ['ROLE_FOOBAR'], + 'ROLE_FOO_*' => ['ROLE_BAR_A', 'ROLE_FOO'], + 'ROLE_BAR_*' => ['ROLE_BAZ'], + ])); $this->assertSame($expected, $voter->vote($this->getTokenWithRoleNames($roles), null, $attributes)); } @@ -31,6 +35,9 @@ public static function getVoteTests() { return array_merge(parent::getVoteTests(), [ [['ROLE_FOO'], ['ROLE_FOOBAR'], VoterInterface::ACCESS_GRANTED], + [['ROLE_FOO_A'], ['ROLE_BAR_A'], VoterInterface::ACCESS_GRANTED], // ROLE_FOO_A => ROLE_FOO_* => ROLE_BAR_A + [['ROLE_FOO_A'], ['ROLE_FOOBAR'], VoterInterface::ACCESS_GRANTED], // ROLE_FOO_A => ROLE_FOO_* => ROLE_FOO => ROLE_FOOBAR + [['ROLE_FOO_A'], ['ROLE_BAZ'], VoterInterface::ACCESS_GRANTED], // ROLE_FOO_A => ROLE_FOO_* => ROLE_BAR_A => ROLE_BAR_* => ROLE_BAZ ]); } diff --git a/src/Symfony/Component/Security/Core/Tests/Role/RoleHierarchyTest.php b/src/Symfony/Component/Security/Core/Tests/Role/RoleHierarchyTest.php index 5c42e0b39f8bf..21e091326c438 100644 --- a/src/Symfony/Component/Security/Core/Tests/Role/RoleHierarchyTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Role/RoleHierarchyTest.php @@ -30,4 +30,44 @@ public function testGetReachableRoleNames() $this->assertEquals(['ROLE_SUPER_ADMIN', 'ROLE_ADMIN', 'ROLE_FOO', 'ROLE_USER'], $role->getReachableRoleNames(['ROLE_SUPER_ADMIN'])); $this->assertEquals(['ROLE_SUPER_ADMIN', 'ROLE_ADMIN', 'ROLE_FOO', 'ROLE_USER'], $role->getReachableRoleNames(['ROLE_SUPER_ADMIN', 'ROLE_SUPER_ADMIN'])); } + + public function testGetReachableRoleNamesWithPlaceholders() + { + $role = new RoleHierarchy([ + 'ROLE_BAZ_*' => ['ROLE_USER'], + 'ROLE_FOO_*' => ['ROLE_BAZ_FOO'], + 'ROLE_BAR_*' => ['ROLE_BAZ_BAR'], + ]); + + $this->assertEquals(['ROLE_BAZ_A', 'ROLE_USER'], $role->getReachableRoleNames(['ROLE_BAZ_A'])); + $this->assertEquals(['ROLE_FOO_A', 'ROLE_BAZ_FOO', 'ROLE_USER'], $role->getReachableRoleNames(['ROLE_FOO_A'])); + + // Multiple roles matching the same placeholder + $this->assertEquals(['ROLE_FOO_A', 'ROLE_FOO_B', 'ROLE_BAZ_FOO', 'ROLE_USER'], $role->getReachableRoleNames(['ROLE_FOO_A', 'ROLE_FOO_B'])); + + // Multiple roles matching multiple placeholders + $this->assertEquals(['ROLE_FOO_A', 'ROLE_BAR_A', 'ROLE_BAZ_FOO', 'ROLE_BAZ_BAR', 'ROLE_USER'], $role->getReachableRoleNames(['ROLE_FOO_A', 'ROLE_BAR_A'])); + } + + public function testGetReachableRoleNamesWithRecursivePlaceholders() + { + $role = new RoleHierarchy([ + 'ROLE_FOO_*' => ['ROLE_BAR_BAZ'], + 'ROLE_BAR_*' => ['ROLE_FOO_BAZ'], + 'ROLE_QUX_*' => ['ROLE_QUX_BAZ'], + ]); + + // ROLE_FOO_* expanded once + $this->assertEquals(['ROLE_FOO_A', 'ROLE_BAR_BAZ', 'ROLE_FOO_BAZ'], $role->getReachableRoleNames(['ROLE_FOO_A'])); + + // ROLE_FOO_* expanded once even with multiple ROLE_FOO_* input roles + $this->assertEquals(['ROLE_FOO_A', 'ROLE_FOO_B', 'ROLE_BAR_BAZ', 'ROLE_FOO_BAZ'], $role->getReachableRoleNames(['ROLE_FOO_A', 'ROLE_FOO_B'])); + + // ROLE_BAR_* expanded once with ROLE_FOO_A => ROLE_FOO_* => ROLE_BAR_BAZ => ROLE_BAR_* => ROLE_FOO_BAZ + $this->assertEquals(['ROLE_FOO_A', 'ROLE_BAR_A', 'ROLE_BAR_BAZ', 'ROLE_FOO_BAZ'], $role->getReachableRoleNames(['ROLE_FOO_A', 'ROLE_BAR_A'])); + + // Self matching placeholder + $this->assertEquals(['ROLE_QUX_A', 'ROLE_QUX_BAZ'], $role->getReachableRoleNames(['ROLE_QUX_A'])); + $this->assertEquals(['ROLE_QUX_BAZ'], $role->getReachableRoleNames(['ROLE_QUX_BAZ'])); + } } From c7abae26d994b43db24b7d6a7c94f6b05d67a003 Mon Sep 17 00:00:00 2001 From: Nicolas Rigaud Date: Fri, 20 Oct 2023 20:58:02 +0200 Subject: [PATCH 2/5] Sanitize/improve placeholder patterns --- src/Symfony/Component/Security/Core/Role/RoleHierarchy.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php b/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php index 60302696d9556..c1f86594dbd33 100644 --- a/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php +++ b/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php @@ -42,7 +42,7 @@ public function __construct(array $hierarchy) public function getReachableRoleNames(array $roles): array { - return \array_values(\array_unique($this->resolveReachableRoleNames($roles))); + return array_values(array_unique($this->resolveReachableRoleNames($roles))); } protected function buildRoleMap(): void From b423851d7823d5f619aee00d35ae08cca538bb2b Mon Sep 17 00:00:00 2001 From: Nicolas Rigaud Date: Fri, 20 Oct 2023 21:01:21 +0200 Subject: [PATCH 3/5] Sanitize/improve placeholder patterns --- .../Security/Core/Role/RoleHierarchy.php | 21 +++++++++++++++++-- .../Core/Tests/Role/RoleHierarchyTest.php | 15 +++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php b/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php index c1f86594dbd33..09f9d3edc27f7 100644 --- a/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php +++ b/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php @@ -72,8 +72,8 @@ protected function buildRoleMap(): void $this->map[$main] = array_unique($this->map[$main]); - if (str_contains($main, '*')) { - $this->rolePlaceholdersPatterns[$main] = sprintf('/%s/', strtr($main, ['*' => '[^\*]+'])); + if (str_contains($main, '*') && false !== ($pattern = $this->getPlaceholderPattern($main))) { + $this->rolePlaceholdersPatterns[$main] = $pattern; } } } @@ -116,4 +116,21 @@ private function getMatchingPlaceholders(array $roles): array return $resolved; } + + /** + * Build the regex pattern for the given role: + * - Replace valid wildcards with a non-wildcard matching pattern and + * - Escape reserved regex characters. + * + * A valid wildcard is a * prefixed with _ and immediately followed by _ or EOL. + * + * @return string|false The regex pattern, or false if there is no valid wildcard in the role + */ + private function getPlaceholderPattern(string $role): string|false + { + /** @var int $count */ + $placeholderPattern = preg_replace(pattern: '/(?<=_)\\\\\*(?=_|$)/', replacement: '[^\*]*', subject: preg_quote($role), count: $count); + + return ($count > 0) ? sprintf('/%s/', $placeholderPattern) : false; + } } diff --git a/src/Symfony/Component/Security/Core/Tests/Role/RoleHierarchyTest.php b/src/Symfony/Component/Security/Core/Tests/Role/RoleHierarchyTest.php index 21e091326c438..0724d1cf6346a 100644 --- a/src/Symfony/Component/Security/Core/Tests/Role/RoleHierarchyTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Role/RoleHierarchyTest.php @@ -70,4 +70,19 @@ public function testGetReachableRoleNamesWithRecursivePlaceholders() $this->assertEquals(['ROLE_QUX_A', 'ROLE_QUX_BAZ'], $role->getReachableRoleNames(['ROLE_QUX_A'])); $this->assertEquals(['ROLE_QUX_BAZ'], $role->getReachableRoleNames(['ROLE_QUX_BAZ'])); } + + public function testInvalidPlaceholderSyntaxAreNotResolved() + { + $role = new RoleHierarchy([ + 'ROLE_FOO*' => ['ROLE_FOOBAR'], + 'ROLE_*FOO' => ['ROLE_FOOBAR'], + 'ROLE_FOO_*BAR' => ['ROLE_FOOBAR'], + 'ROLE_FOO*_BAR' => ['ROLE_FOOBAR'], + ]); + + $this->assertEquals(['ROLE_FOOA'], $role->getReachableRoleNames(['ROLE_FOOA'])); + $this->assertEquals(['ROLE_AFOO'], $role->getReachableRoleNames(['ROLE_AFOO'])); + $this->assertEquals(['ROLE_FOO_ABAR'], $role->getReachableRoleNames(['ROLE_FOO_ABAR'])); + $this->assertEquals(['ROLE_FOOA_BAR'], $role->getReachableRoleNames(['ROLE_FOOA_BAR'])); + } } From 5a576b0e004d0632ec9fb584d464d5879a0fa0cd Mon Sep 17 00:00:00 2001 From: Nicolas Rigaud Date: Mon, 23 Oct 2023 16:52:15 +0200 Subject: [PATCH 4/5] Ensure placeholders strictly matches roles --- src/Symfony/Component/Security/Core/Role/RoleHierarchy.php | 2 +- .../Component/Security/Core/Tests/Role/RoleHierarchyTest.php | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php b/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php index 09f9d3edc27f7..d3a854557cbf9 100644 --- a/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php +++ b/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php @@ -131,6 +131,6 @@ private function getPlaceholderPattern(string $role): string|false /** @var int $count */ $placeholderPattern = preg_replace(pattern: '/(?<=_)\\\\\*(?=_|$)/', replacement: '[^\*]*', subject: preg_quote($role), count: $count); - return ($count > 0) ? sprintf('/%s/', $placeholderPattern) : false; + return ($count > 0) ? sprintf('/^%s$/', $placeholderPattern) : false; } } diff --git a/src/Symfony/Component/Security/Core/Tests/Role/RoleHierarchyTest.php b/src/Symfony/Component/Security/Core/Tests/Role/RoleHierarchyTest.php index 0724d1cf6346a..cda1b0c3200e7 100644 --- a/src/Symfony/Component/Security/Core/Tests/Role/RoleHierarchyTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Role/RoleHierarchyTest.php @@ -37,6 +37,7 @@ public function testGetReachableRoleNamesWithPlaceholders() 'ROLE_BAZ_*' => ['ROLE_USER'], 'ROLE_FOO_*' => ['ROLE_BAZ_FOO'], 'ROLE_BAR_*' => ['ROLE_BAZ_BAR'], + 'ROLE_QUX_*_BAR' => ['ROLE_FOOBAR'], ]); $this->assertEquals(['ROLE_BAZ_A', 'ROLE_USER'], $role->getReachableRoleNames(['ROLE_BAZ_A'])); @@ -47,6 +48,10 @@ public function testGetReachableRoleNamesWithPlaceholders() // Multiple roles matching multiple placeholders $this->assertEquals(['ROLE_FOO_A', 'ROLE_BAR_A', 'ROLE_BAZ_FOO', 'ROLE_BAZ_BAR', 'ROLE_USER'], $role->getReachableRoleNames(['ROLE_FOO_A', 'ROLE_BAR_A'])); + + // Test placeholders don't match more than the pattern + $this->assertEquals(['FOO_ROLE_FOO_A'], $role->getReachableRoleNames(['FOO_ROLE_FOO_A'])); // Doesn't start with ROLE_FOO_ + $this->assertEquals(['ROLE_QUX_A_BARA'], $role->getReachableRoleNames(['ROLE_QUX_A_BARA'])); // Doesn't end with _BAR } public function testGetReachableRoleNamesWithRecursivePlaceholders() From 5c638504619b95fac7eddab55445f43a05cd8c66 Mon Sep 17 00:00:00 2001 From: Nicolas Rigaud Date: Thu, 2 Nov 2023 08:56:22 +0100 Subject: [PATCH 5/5] Add a debug:roles command --- .../Bundle/SecurityBundle/CHANGELOG.md | 1 + .../Command/DebugRolesCommand.php | 207 ++++++++++++++++++ .../Debug/DebugRoleHierarchy.php | 105 +++++++++ .../RegisterDebugRoleHierarchyPass.php | 38 ++++ .../Resources/config/debug_console.php | 6 + .../Bundle/SecurityBundle/SecurityBundle.php | 3 + .../Tests/Command/DebugRolesCommandTest.php | 151 +++++++++++++ .../Tests/Debug/DebugRoleHierarchyTest.php | 99 +++++++++ .../Security/Core/Role/RoleHierarchy.php | 18 +- 9 files changed, 619 insertions(+), 9 deletions(-) create mode 100644 src/Symfony/Bundle/SecurityBundle/Command/DebugRolesCommand.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Debug/DebugRoleHierarchy.php create mode 100644 src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterDebugRoleHierarchyPass.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Command/DebugRolesCommandTest.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Debug/DebugRoleHierarchyTest.php diff --git a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md index f704e00d92de1..8ca6d5e132a89 100644 --- a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md @@ -17,6 +17,7 @@ CHANGELOG 6.4 --- + * Add the `debug:roles` command to debug role hierarchy * Deprecate `Security::ACCESS_DENIED_ERROR`, `AUTHENTICATION_ERROR` and `LAST_USERNAME` constants, use the ones on `SecurityRequestAttributes` instead * Allow an array of `pattern` in firewall configuration * Add `$badges` argument to `Security::login` diff --git a/src/Symfony/Bundle/SecurityBundle/Command/DebugRolesCommand.php b/src/Symfony/Bundle/SecurityBundle/Command/DebugRolesCommand.php new file mode 100644 index 0000000000000..13f927ddc19ee --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Command/DebugRolesCommand.php @@ -0,0 +1,207 @@ + + * + * 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\Bundle\SecurityBundle\Debug\DebugRoleHierarchy; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +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\Role\RoleHierarchyInterface; + +#[AsCommand(name: 'debug:roles', description: 'Debug the role hierarchy configuration.')] +final class DebugRolesCommand extends Command +{ + public function __construct(private readonly RoleHierarchyInterface $roleHierarchy) + { + parent::__construct(); + } + + protected function configure(): void + { + $this->setHelp(<<%command.name% command display the current role hierarchy: + + php %command.full_name% + +You can pass one or multiple role names to display the effective roles: + + php %command.full_name% ROLE_USER + +To get a tree view of the inheritance, use the tree option: + + php %command.full_name% --tree + php %command.full_name% ROLE_USER --tree + +Note: With a custom implementation for security.role_hierarchy, the --tree option is ignored and the roles argument is required. + +EOF + ) + ->setDefinition([ + new InputArgument('roles', ($this->isBuiltInRoleHierarchy() ? InputArgument::OPTIONAL : InputArgument::REQUIRED) | InputArgument::IS_ARRAY, 'The role(s) to resolve'), + new InputOption('tree', 't', InputOption::VALUE_NONE, 'Show the hierarchy in a tree view'), + ]); + } + + protected function initialize(InputInterface $input, OutputInterface $output): void + { + if (!$this->isBuiltInRoleHierarchy()) { + $io = new SymfonyStyle($input, $output); + + if ($input->getOption('tree')) { + $io->warning('Ignoring option "--tree" because of a custom role hierarchy implementation.'); + $input->setOption('tree', null); + } + } + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + if (!$this->isBuiltInRoleHierarchy() && empty($input->getArgument('roles'))) { + $io = new SymfonyStyle($input, $output); + + $roles[] = $io->ask('Enter a role to debug', validator: function (?string $role) { + $role = trim($role); + if (empty($role)) { + throw new \RuntimeException('You must enter a non empty role name.'); + } + + return $role; + }); + while ($role = trim($io->ask('Add another role? (press enter to skip)') ?? '')) { + $roles[] = $role; + } + + $input->setArgument('roles', $roles); + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $roles = $input->getArgument('roles'); + + if (empty($roles)) { + // Full configuration output + $io->title('Current role hierarchy configuration:'); + + if ($input->getOption('tree')) { + $this->outputTree($io, $this->getBuiltInDebugHierarchy()->getHierarchy()); + } else { + $this->outputMap($io, $this->getBuiltInDebugHierarchy()->getMap()); + } + + $io->comment('To show reachable roles for a given role, re-run this command with role names. (e.g. debug:roles ROLE_USER)'); + + return self::SUCCESS; + } + + // Matching roles output + $io->title(sprintf('Effective roles for %s:', implode(', ', array_map(fn ($v) => sprintf('%s', $v), $roles)))); + + if ($input->getOption('tree')) { + $this->outputTree($io, $this->getBuiltInDebugHierarchy()->getHierarchy($roles)); + } else { + $io->listing($this->roleHierarchy->getReachableRoleNames($roles)); + } + + return self::SUCCESS; + } + + private function outputMap(OutputInterface $output, array $map): void + { + foreach ($map as $main => $roles) { + if ($this->getBuiltInDebugHierarchy()->isPlaceholder($main)) { + $main = $this->stylePlaceholder($main); + } + + $output->writeln(sprintf('%s:', $main)); + foreach ($roles as $r) { + $output->writeln(sprintf(' - %s', $r)); + } + $output->writeln(''); + } + } + + private function outputTree(OutputInterface $output, array $tree): void + { + foreach ($tree as $role => $hierarchy) { + $output->writeln($this->generateTree($role, $hierarchy)); + $output->writeln(''); + } + } + + /** + * Generates a tree representation, line by line, in the tree unix style. + * + * Example output: + * + * ROLE_A + * └── ROLE_B + * + * ROLE_C + * ├── ROLE_A + * │ └── ROLE_B + * └── ROLE_D + */ + private function generateTree(string $name, array $tree, string $indent = '', bool $last = true, bool $root = true): \Generator + { + if ($this->getBuiltInDebugHierarchy()->isPlaceholder($name)) { + $name = $this->stylePlaceholder($name); + } + + if ($root) { + // Yield root node as it is + yield $name; + } else { + // Generate line in the tree: + // Line: [indent]├── [name] + // Last line: [indent]└── [name] + yield sprintf('%s%s%s %s', $indent, $last ? "\u{2514}" : "\u{251c}", str_repeat("\u{2500}", 2), $name); + + // Update indent for next nested: + // Append "| " for a nested tree + // Append " " for last nested tree + $indent .= ($last ? ' ' : "\u{2502}").str_repeat(' ', 3); + } + + $i = 0; + $count = \count($tree); + foreach ($tree as $key => $value) { + yield from $this->generateTree($key, $value, $indent, $i === $count - 1, false); + ++$i; + } + } + + private function stylePlaceholder(string $role): string + { + return sprintf('%s', $role); + } + + private function isBuiltInRoleHierarchy(): bool + { + return $this->roleHierarchy instanceof DebugRoleHierarchy; + } + + private function getBuiltInDebugHierarchy(): DebugRoleHierarchy + { + if (!$this->roleHierarchy instanceof DebugRoleHierarchy) { + throw new \LogicException('Cannot use the built-in debug hierarchy with a custom implementation.'); + } + + return $this->roleHierarchy; + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Debug/DebugRoleHierarchy.php b/src/Symfony/Bundle/SecurityBundle/Debug/DebugRoleHierarchy.php new file mode 100644 index 0000000000000..808a461b002eb --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Debug/DebugRoleHierarchy.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Debug; + +use Symfony\Component\Security\Core\Role\RoleHierarchy; + +/** + * Extended Role Hierarchy to access inner configuration data. + * + * @author Nicolas Rigaud + * + * @internal + */ +final class DebugRoleHierarchy extends RoleHierarchy +{ + private readonly array $debugHierarchy; + + public function __construct(array $hierarchy) + { + $this->debugHierarchy = $hierarchy; + + parent::__construct($hierarchy); + } + + /** + * Get the hierarchy tree. + * + * Example output: + * + * [ + * 'ROLE_A' => [ + * 'ROLE_B' => [], + * 'ROLE_C' => [ + * 'ROLE_D' => [] + * ] + * ], + * 'ROLE_C' => [ + * 'ROLE_D' => [] + * ] + * ] + * + * @param string[] $roles Optionally restrict the tree to these roles + * + * @return array> + */ + public function getHierarchy(array $roles = []): array + { + $hierarchy = []; + + foreach ($roles ?: array_keys($this->debugHierarchy) as $role) { + $hierarchy += $this->buildHierarchy([$role]); + } + + return $hierarchy; + } + + /** + * Get the computed role map. + * + * @return array + */ + public function getMap(): array + { + return $this->map; + } + + /** + * Return whether a given role is processed as a placeholder. + */ + public function isPlaceholder(string $role): bool + { + return \in_array($role, array_keys($this->rolePlaceholdersPatterns)); + } + + private function buildHierarchy(array $roles, array &$visited = []): array + { + $tree = []; + foreach ($roles as $role) { + $visited[] = $role; + + $tree[$role] = []; + + // Get placeholders matches + $placeholders = array_diff($this->getMatchingPlaceholders([$role]), $visited) ?? []; + array_push($visited, ...$placeholders); + $tree[$role] += $this->buildHierarchy($placeholders, $visited); + + // Get regular inherited roles + $inherited = array_diff($this->debugHierarchy[$role] ?? [], $visited); + array_push($visited, ...$inherited); + $tree[$role] += $this->buildHierarchy($inherited, $visited); + } + + return $tree; + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterDebugRoleHierarchyPass.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterDebugRoleHierarchyPass.php new file mode 100644 index 0000000000000..87b18c7bb8b36 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterDebugRoleHierarchyPass.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler; + +use Symfony\Bundle\SecurityBundle\Debug\DebugRoleHierarchy; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\Security\Core\Role\RoleHierarchy; + +class RegisterDebugRoleHierarchyPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + if (!$container->hasDefinition('security.role_hierarchy')) { + $container->removeDefinition('security.command.debug_role_hierarchy'); + + return; + } + + $definition = $container->findDefinition('security.role_hierarchy'); + + if (RoleHierarchy::class === $definition->getClass()) { + $hierarchy = $definition->getArgument(0); + $definition = new Definition(DebugRoleHierarchy::class, [$hierarchy]); + } + $container->setDefinition('debug.security.role_hierarchy', $definition); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/debug_console.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/debug_console.php index 74fa434926063..f3e87dea872d2 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\DebugRolesCommand; return static function (ContainerConfigurator $container) { $container->services() @@ -24,5 +25,10 @@ false, ]) ->tag('console.command', ['command' => 'debug:firewall']) + ->set('security.command.debug_role_hierarchy', DebugRolesCommand::class) + ->args([ + service('debug.security.role_hierarchy'), + ]) + ->tag('console.command', ['command' => 'debug:roles']) ; }; diff --git a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php index 3247ff1276ffa..cbc97f68d3386 100644 --- a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php +++ b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php @@ -17,6 +17,7 @@ use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\CleanRememberMeVerifierPass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\MakeFirewallsEventDispatcherTraceablePass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterCsrfFeaturesPass; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterDebugRoleHierarchyPass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterEntryPointPass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterGlobalSecurityEventListenersPass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterLdapLocatorPass; @@ -105,5 +106,7 @@ public function build(ContainerBuilder $container): void // must be registered before DecoratorServicePass $container->addCompilerPass(new MakeFirewallsEventDispatcherTraceablePass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 10); + + $container->addCompilerPass(new RegisterDebugRoleHierarchyPass(), PassConfig::TYPE_OPTIMIZE); } } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Command/DebugRolesCommandTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Command/DebugRolesCommandTest.php new file mode 100644 index 0000000000000..78ca38fd501f9 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Command/DebugRolesCommandTest.php @@ -0,0 +1,151 @@ + + * + * 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\DebugRolesCommand; +use Symfony\Bundle\SecurityBundle\Debug\DebugRoleHierarchy; +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Exception\RuntimeException; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; + +class DebugRolesCommandTest extends TestCase +{ + public function testDebugBuiltInRoleHierarchy() + { + $roleHierarchy = new DebugRoleHierarchy([ + 'ROLE_FOO' => ['ROLE_BAR'], + 'ROLE_BAR' => ['ROLE_BAZ'], + ]); + + $tester = $this->createCommandTester($roleHierarchy); + + $tester->execute([]); + + $tester->assertCommandIsSuccessful(); + $expected = <<assertStringContainsString($expected, $tester->getDisplay()); + } + + public function testDebugBuiltInHierarchyWithTreeOption() + { + $roleHierarchy = new DebugRoleHierarchy([ + 'ROLE_FOO' => ['ROLE_BAR'], + 'ROLE_BAR' => ['ROLE_BAZ'], + ]); + + $tester = $this->createCommandTester($roleHierarchy); + + $tester->execute(['--tree' => true]); + + $tester->assertCommandIsSuccessful(); + $expected = <<assertStringContainsString($expected, $tester->getDisplay()); + } + + public function testDebugCustomRoleHierarchy() + { + $roleHierarchy = $this->createMock(RoleHierarchyInterface::class); + $roleHierarchy + ->expects($this->once()) + ->method('getReachableRoleNames') + ->with(['ROLE_FOO']) + ->willReturn([ + 'ROLE_FOO', + 'ROLE_BAR', + ]); + $tester = $this->createCommandTester($roleHierarchy); + + $tester->execute(['roles' => ['ROLE_FOO']]); + + $tester->assertCommandIsSuccessful(); + $expected = <<assertStringContainsString($expected, $tester->getDisplay()); + } + + public function testDebugCustomRoleHierarchyWithNoArgumentsAsksInteractively() + { + $roleHierarchy = $this->createMock(RoleHierarchyInterface::class); + $roleHierarchy + ->expects($this->once()) + ->method('getReachableRoleNames') + ->with(['ROLE_FOO']) + ->willReturnArgument(0); + $tester = $this->createCommandTester($roleHierarchy); + + $tester->setInputs(['ROLE_FOO', '']); + $tester->execute([], ['interactive' => true]); + + $tester->assertCommandIsSuccessful(); + $this->assertStringContainsString('Enter a role to debug', $tester->getDisplay()); + $this->assertEquals(['ROLE_FOO'], $tester->getInput()->getArgument('roles')); + } + + public function testDebugCustomRoleHierarchyRequiresRoleArgument() + { + $roleHierarchy = $this->createMock(RoleHierarchyInterface::class); + + $tester = $this->createCommandTester($roleHierarchy); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Not enough arguments (missing: "roles").'); + + $tester->execute([], ['interactive' => false]); + } + + public function testDebugCustomRoleHierarchyIgnoresTreeOption() + { + $roleHierarchy = $this->createMock(RoleHierarchyInterface::class); + $roleHierarchy + ->expects($this->once()) + ->method('getReachableRoleNames') + ->with(['ROLE_FOO']) + ->willReturnArgument(0); + + $tester = $this->createCommandTester($roleHierarchy); + + $tester->execute(['roles' => ['ROLE_FOO'], '--tree' => true]); + + $tester->assertCommandIsSuccessful(); + $this->assertNull($tester->getInput()->getOption('tree')); + $this->assertStringContainsString('Ignoring option "--tree"', $tester->getDisplay()); + } + + private function createCommandTester(RoleHierarchyInterface $roleHierarchy): CommandTester + { + $application = new Application(); + $command = new DebugRolesCommand($roleHierarchy); + $application->add($command); + + return new CommandTester($command); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Debug/DebugRoleHierarchyTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Debug/DebugRoleHierarchyTest.php new file mode 100644 index 0000000000000..487e8259ed363 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Debug/DebugRoleHierarchyTest.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\Debug; + +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\SecurityBundle\Debug\DebugRoleHierarchy; + +class DebugRoleHierarchyTest extends TestCase +{ + public function testBuildHierarchy() + { + $hierarchy = [ + 'ROLE_FOO' => ['ROLE_BAR'], + 'ROLE_FOO_BAR' => ['ROLE_BAZ'], + ]; + + $debugRoleHierarchy = new DebugRoleHierarchy($hierarchy); + + $this->assertNotEmpty($debugRoleHierarchy->getMap()); + $this->assertEquals([ + 'ROLE_FOO' => [ + 'ROLE_BAR' => [], + ], + 'ROLE_FOO_BAR' => [ + 'ROLE_BAZ' => [], + ], + ], $debugRoleHierarchy->getHierarchy()); + } + + public function testBuildHierarchyWithPlaceholders() + { + $debugRoleHierarchy = new DebugRoleHierarchy([ + 'ROLE_FOOBAR' => ['ROLE_QUX'], + 'ROLE_FOO_*' => ['ROLE_FOOBAR'], + 'ROLE_BAR_*' => ['ROLE_BAR_FOO'], + 'ROLE_BAZ_*' => ['ROLE_FOO_BAR'], + ]); + + foreach (['ROLE_FOO_*', 'ROLE_BAR_*', 'ROLE_BAZ_*'] as $placeholder) { + $this->assertTrue($debugRoleHierarchy->isPlaceholder($placeholder)); + } + $this->assertFalse($debugRoleHierarchy->isPlaceholder('ROLE_FOOBAR')); + + // Test full hierarchy tree + $this->assertEquals([ + 'ROLE_FOOBAR' => [ + 'ROLE_QUX' => [], + ], + 'ROLE_FOO_*' => [ + 'ROLE_FOOBAR' => [ + 'ROLE_QUX' => [], + ], + ], + 'ROLE_BAR_*' => [ + 'ROLE_BAR_FOO' => [], + ], + 'ROLE_BAZ_*' => [ + 'ROLE_FOO_BAR' => [ + 'ROLE_FOO_*' => [ + 'ROLE_FOOBAR' => [ + 'ROLE_QUX' => [], + ], + ], + ], + ], + ], $debugRoleHierarchy->getHierarchy()); + + // Test hierarchy tree for given roles + $this->assertEquals([ + 'ROLE_BAZ_A' => [ + 'ROLE_BAZ_*' => [ + 'ROLE_FOO_BAR' => [ + 'ROLE_FOO_*' => [ + 'ROLE_FOOBAR' => [ + 'ROLE_QUX' => [], + ], + ], + ], + ], + ], + 'ROLE_FOO_A' => [ + 'ROLE_FOO_*' => [ + 'ROLE_FOOBAR' => [ + 'ROLE_QUX' => [], + ], + ], + ], + ], $debugRoleHierarchy->getHierarchy(['ROLE_BAZ_A', 'ROLE_FOO_A'])); + } +} diff --git a/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php b/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php index d3a854557cbf9..3712469b83b02 100644 --- a/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php +++ b/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php @@ -23,7 +23,7 @@ class RoleHierarchy implements RoleHierarchyInterface * * @var array */ - private array $rolePlaceholdersPatterns; + protected array $rolePlaceholdersPatterns; /** @var array> */ protected array $map; @@ -72,7 +72,7 @@ protected function buildRoleMap(): void $this->map[$main] = array_unique($this->map[$main]); - if (str_contains($main, '*') && false !== ($pattern = $this->getPlaceholderPattern($main))) { + if (str_contains($main, '*') && null !== ($pattern = $this->getPlaceholderPattern($main))) { $this->rolePlaceholdersPatterns[$main] = $pattern; } } @@ -104,12 +104,12 @@ private function resolveReachableRoleNames(array $roles, array &$visitedPlacehol return $reachableRoles; } - private function getMatchingPlaceholders(array $roles): array + protected function getMatchingPlaceholders(array $roles): array { $resolved = []; foreach ($this->rolePlaceholdersPatterns as $placeholder => $pattern) { - if (!\in_array($placeholder, $resolved) && \count(preg_grep($pattern, $roles) ?? null)) { + if (!\in_array($placeholder, $resolved) && \count(preg_grep($pattern, $roles) ?: [])) { $resolved[] = $placeholder; } } @@ -119,18 +119,18 @@ private function getMatchingPlaceholders(array $roles): array /** * Build the regex pattern for the given role: - * - Replace valid wildcards with a non-wildcard matching pattern and + * - Replace valid wildcards with a non-wildcard matching pattern. * - Escape reserved regex characters. * * A valid wildcard is a * prefixed with _ and immediately followed by _ or EOL. * - * @return string|false The regex pattern, or false if there is no valid wildcard in the role + * @return string|null The regex pattern, or null if there is no valid wildcard in the role */ - private function getPlaceholderPattern(string $role): string|false + private function getPlaceholderPattern(string $role): ?string { /** @var int $count */ - $placeholderPattern = preg_replace(pattern: '/(?<=_)\\\\\*(?=_|$)/', replacement: '[^\*]*', subject: preg_quote($role), count: $count); + $placeholderPattern = preg_replace(pattern: '/(?<=_)\\\\\*(?=_|$)/', replacement: '.*', subject: preg_quote($role), count: $count); - return ($count > 0) ? sprintf('/^%s$/', $placeholderPattern) : false; + return ($count > 0) ? sprintf('/^%s$/', $placeholderPattern) : null; } }