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/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..3712469b83b02 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 + */ + protected 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,66 @@ protected function buildRoleMap(): void } $this->map[$main] = array_unique($this->map[$main]); + + if (str_contains($main, '*') && null !== ($pattern = $this->getPlaceholderPattern($main))) { + $this->rolePlaceholdersPatterns[$main] = $pattern; + } + } + } + + 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; + } + + protected function getMatchingPlaceholders(array $roles): array + { + $resolved = []; + + foreach ($this->rolePlaceholdersPatterns as $placeholder => $pattern) { + if (!\in_array($placeholder, $resolved) && \count(preg_grep($pattern, $roles) ?: [])) { + $resolved[] = $placeholder; + } + } + + return $resolved; + } + + /** + * Build the regex pattern for the given role: + * - 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|null The regex pattern, or null if there is no valid wildcard in the role + */ + private function getPlaceholderPattern(string $role): ?string + { + /** @var int $count */ + $placeholderPattern = preg_replace(pattern: '/(?<=_)\\\\\*(?=_|$)/', replacement: '.*', subject: preg_quote($role), count: $count); + + return ($count > 0) ? sprintf('/^%s$/', $placeholderPattern) : null; } } 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..cda1b0c3200e7 100644 --- a/src/Symfony/Component/Security/Core/Tests/Role/RoleHierarchyTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Role/RoleHierarchyTest.php @@ -30,4 +30,64 @@ 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'], + 'ROLE_QUX_*_BAR' => ['ROLE_FOOBAR'], + ]); + + $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'])); + + // 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() + { + $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'])); + } + + 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'])); + } }