Skip to content

Commit ae44f50

Browse files
committed
[Security] Handle placeholders in role hierarchy
1 parent abe5555 commit ae44f50

File tree

4 files changed

+103
-14
lines changed

4 files changed

+103
-14
lines changed

src/Symfony/Component/Security/Core/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
6.4
55
---
66

7+
* Allow using wildcards as placeholders in `RoleHierarchy` map's keys
78
* Make `PersistentToken` immutable
89
* Deprecate accepting only `DateTime` for `TokenProviderInterface::updateToken()`, use `DateTimeInterface` instead
910

src/Symfony/Component/Security/Core/Role/RoleHierarchy.php

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@
1919
class RoleHierarchy implements RoleHierarchyInterface
2020
{
2121
private array $hierarchy;
22+
23+
/**
24+
* Map role placeholders with their regex pattern.
25+
*
26+
* @var array<string,string>
27+
*/
28+
private array $rolePlaceholdersPatterns;
29+
2230
/** @var array<string, list<string>> */
2331
protected $map;
2432

@@ -34,19 +42,7 @@ public function __construct(array $hierarchy)
3442

3543
public function getReachableRoleNames(array $roles): array
3644
{
37-
$reachableRoles = $roles;
38-
39-
foreach ($roles as $role) {
40-
if (!isset($this->map[$role])) {
41-
continue;
42-
}
43-
44-
foreach ($this->map[$role] as $r) {
45-
$reachableRoles[] = $r;
46-
}
47-
}
48-
49-
return array_values(array_unique($reachableRoles));
45+
return $this->resolveReachableRoleNames($roles);
5046
}
5147

5248
/**
@@ -55,6 +51,8 @@ public function getReachableRoleNames(array $roles): array
5551
protected function buildRoleMap()
5652
{
5753
$this->map = [];
54+
$this->rolePlaceholdersPatterns = [];
55+
5856
foreach ($this->hierarchy as $main => $roles) {
5957
$this->map[$main] = $roles;
6058
$visited = [];
@@ -76,6 +74,49 @@ protected function buildRoleMap()
7674
}
7775

7876
$this->map[$main] = array_unique($this->map[$main]);
77+
78+
if (str_contains($main, '*')) {
79+
$this->rolePlaceholdersPatterns[$main] = sprintf('/%s/', strtr($main, ['*' => '[^\*]+']));
80+
}
7981
}
8082
}
83+
84+
private function resolveReachableRoleNames(array $roles, array &$visitedPlaceholders = []): array
85+
{
86+
$reachableRoles = $roles;
87+
88+
foreach ($roles as $role) {
89+
if (!isset($this->map[$role])) {
90+
continue;
91+
}
92+
93+
foreach ($this->map[$role] as $r) {
94+
$reachableRoles[] = $r;
95+
}
96+
}
97+
98+
$placeholderRoles = array_diff($this->getMatchingPlaceholders($reachableRoles), $visitedPlaceholders);
99+
if (!empty($placeholderRoles)) {
100+
array_push($visitedPlaceholders, ...$placeholderRoles);
101+
$resolvedPlaceholderRoles = $this->resolveReachableRoleNames($placeholderRoles, $visitedPlaceholders);
102+
foreach (array_diff($resolvedPlaceholderRoles, $placeholderRoles) as $r) {
103+
$reachableRoles[] = $r;
104+
}
105+
}
106+
107+
return array_values(array_unique($reachableRoles));
108+
}
109+
110+
private function getMatchingPlaceholders(array $roles): array
111+
{
112+
$resolved = [];
113+
114+
foreach ($this->rolePlaceholdersPatterns as $placeholder => $pattern) {
115+
if (!\in_array($placeholder, $resolved) && \count(preg_grep($pattern, $roles) ?? null)) {
116+
$resolved[] = $placeholder;
117+
}
118+
}
119+
120+
return $resolved;
121+
}
81122
}

src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleHierarchyVoterTest.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ class RoleHierarchyVoterTest extends RoleVoterTest
2222
*/
2323
public function testVoteUsingTokenThatReturnsRoleNames($roles, $attributes, $expected)
2424
{
25-
$voter = new RoleHierarchyVoter(new RoleHierarchy(['ROLE_FOO' => ['ROLE_FOOBAR']]));
25+
$voter = new RoleHierarchyVoter(new RoleHierarchy([
26+
'ROLE_FOO' => ['ROLE_FOOBAR'],
27+
'ROLE_FOO_*' => ['ROLE_BAR_A', 'ROLE_FOO'],
28+
'ROLE_BAR_*' => ['ROLE_BAZ'],
29+
]));
2630

2731
$this->assertSame($expected, $voter->vote($this->getTokenWithRoleNames($roles), null, $attributes));
2832
}
@@ -31,6 +35,9 @@ public static function getVoteTests()
3135
{
3236
return array_merge(parent::getVoteTests(), [
3337
[['ROLE_FOO'], ['ROLE_FOOBAR'], VoterInterface::ACCESS_GRANTED],
38+
[['ROLE_FOO_A'], ['ROLE_BAR_A'], VoterInterface::ACCESS_GRANTED], // ROLE_FOO_A => ROLE_FOO_* => ROLE_BAR_A
39+
[['ROLE_FOO_A'], ['ROLE_FOOBAR'], VoterInterface::ACCESS_GRANTED], // ROLE_FOO_A => ROLE_FOO_* => ROLE_FOO => ROLE_FOOBAR
40+
[['ROLE_FOO_A'], ['ROLE_BAZ'], VoterInterface::ACCESS_GRANTED], // ROLE_FOO_A => ROLE_FOO_* => ROLE_BAR_A => ROLE_BAR_* => ROLE_BAZ
3441
]);
3542
}
3643

src/Symfony/Component/Security/Core/Tests/Role/RoleHierarchyTest.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,44 @@ public function testGetReachableRoleNames()
3030
$this->assertEquals(['ROLE_SUPER_ADMIN', 'ROLE_ADMIN', 'ROLE_FOO', 'ROLE_USER'], $role->getReachableRoleNames(['ROLE_SUPER_ADMIN']));
3131
$this->assertEquals(['ROLE_SUPER_ADMIN', 'ROLE_ADMIN', 'ROLE_FOO', 'ROLE_USER'], $role->getReachableRoleNames(['ROLE_SUPER_ADMIN', 'ROLE_SUPER_ADMIN']));
3232
}
33+
34+
public function testGetReachableRoleNamesWithPlaceholders()
35+
{
36+
$role = new RoleHierarchy([
37+
'ROLE_BAZ_*' => ['ROLE_USER'],
38+
'ROLE_FOO_*' => ['ROLE_BAZ_FOO'],
39+
'ROLE_BAR_*' => ['ROLE_BAZ_BAR'],
40+
]);
41+
42+
$this->assertEquals(['ROLE_BAZ_A', 'ROLE_USER'], $role->getReachableRoleNames(['ROLE_BAZ_A']));
43+
$this->assertEquals(['ROLE_FOO_A', 'ROLE_BAZ_FOO', 'ROLE_USER'], $role->getReachableRoleNames(['ROLE_FOO_A']));
44+
45+
// Multiple roles matching the same placeholder
46+
$this->assertEquals(['ROLE_FOO_A', 'ROLE_FOO_B', 'ROLE_BAZ_FOO', 'ROLE_USER'], $role->getReachableRoleNames(['ROLE_FOO_A', 'ROLE_FOO_B']));
47+
48+
// Multiple roles matching multiple placeholders
49+
$this->assertEquals(['ROLE_FOO_A', 'ROLE_BAR_A', 'ROLE_BAZ_FOO', 'ROLE_BAZ_BAR', 'ROLE_USER'], $role->getReachableRoleNames(['ROLE_FOO_A', 'ROLE_BAR_A']));
50+
}
51+
52+
public function testGetReachableRoleNamesWithRecursivePlaceholders()
53+
{
54+
$role = new RoleHierarchy([
55+
'ROLE_FOO_*' => ['ROLE_BAR_BAZ'],
56+
'ROLE_BAR_*' => ['ROLE_FOO_BAZ'],
57+
'ROLE_QUX_*' => ['ROLE_QUX_BAZ'],
58+
]);
59+
60+
// ROLE_FOO_* expanded once
61+
$this->assertEquals(['ROLE_FOO_A', 'ROLE_BAR_BAZ', 'ROLE_FOO_BAZ'], $role->getReachableRoleNames(['ROLE_FOO_A']));
62+
63+
// ROLE_FOO_* expanded once even with multiple ROLE_FOO_* input roles
64+
$this->assertEquals(['ROLE_FOO_A', 'ROLE_FOO_B', 'ROLE_BAR_BAZ', 'ROLE_FOO_BAZ'], $role->getReachableRoleNames(['ROLE_FOO_A', 'ROLE_FOO_B']));
65+
66+
// ROLE_BAR_* expanded once with ROLE_FOO_A => ROLE_FOO_* => ROLE_BAR_BAZ => ROLE_BAR_* => ROLE_FOO_BAZ
67+
$this->assertEquals(['ROLE_FOO_A', 'ROLE_BAR_A', 'ROLE_BAR_BAZ', 'ROLE_FOO_BAZ'], $role->getReachableRoleNames(['ROLE_FOO_A', 'ROLE_BAR_A']));
68+
69+
// Self matching placeholder
70+
$this->assertEquals(['ROLE_QUX_A', 'ROLE_QUX_BAZ'], $role->getReachableRoleNames(['ROLE_QUX_A']));
71+
$this->assertEquals(['ROLE_QUX_BAZ'], $role->getReachableRoleNames(['ROLE_QUX_BAZ']));
72+
}
3373
}

0 commit comments

Comments
 (0)