Skip to content

Commit e60d0ab

Browse files
committed
[Security] Implement ADM strategies as dedicated classes
Signed-off-by: Alexander M. Turek <me@derrabus.de>
1 parent 2e8ee97 commit e60d0ab

14 files changed

+662
-145
lines changed

Authorization/AccessDecisionManager.php

Lines changed: 62 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
namespace Symfony\Component\Security\Core\Authorization;
1313

1414
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
15+
use Symfony\Component\Security\Core\Authorization\Strategy\AccessDecisionStrategyInterface;
16+
use Symfony\Component\Security\Core\Authorization\Strategy\AffirmativeStrategy;
17+
use Symfony\Component\Security\Core\Authorization\Strategy\ConsensusStrategy;
18+
use Symfony\Component\Security\Core\Authorization\Strategy\PriorityStrategy;
19+
use Symfony\Component\Security\Core\Authorization\Strategy\UnanimousStrategy;
1520
use Symfony\Component\Security\Core\Authorization\Voter\CacheableVoterInterface;
1621
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
1722
use Symfony\Component\Security\Core\Exception\InvalidArgumentException;
@@ -21,40 +26,62 @@
2126
* that use decision voters.
2227
*
2328
* @author Fabien Potencier <fabien@symfony.com>
29+
*
30+
* @final since Symfony 5.4
2431
*/
2532
class AccessDecisionManager implements AccessDecisionManagerInterface
2633
{
34+
/**
35+
* @deprecated use {@see AffirmativeStrategy} instead
36+
*/
2737
public const STRATEGY_AFFIRMATIVE = 'affirmative';
38+
39+
/**
40+
* @deprecated use {@see ConsensusStrategy} instead
41+
*/
2842
public const STRATEGY_CONSENSUS = 'consensus';
43+
44+
/**
45+
* @deprecated use {@see UnanimousStrategy} instead
46+
*/
2947
public const STRATEGY_UNANIMOUS = 'unanimous';
48+
49+
/**
50+
* @deprecated use {@see PriorityStrategy} instead
51+
*/
3052
public const STRATEGY_PRIORITY = 'priority';
3153

54+
private const VALID_VOTES = [
55+
VoterInterface::ACCESS_GRANTED => true,
56+
VoterInterface::ACCESS_DENIED => true,
57+
VoterInterface::ACCESS_ABSTAIN => true,
58+
];
59+
3260
private $voters;
3361
private $votersCacheAttributes;
3462
private $votersCacheObject;
3563
private $strategy;
36-
private $allowIfAllAbstainDecisions;
37-
private $allowIfEqualGrantedDeniedDecisions;
3864

3965
/**
40-
* @param iterable|VoterInterface[] $voters An array or an iterator of VoterInterface instances
41-
* @param string $strategy The vote strategy
42-
* @param bool $allowIfAllAbstainDecisions Whether to grant access if all voters abstained or not
43-
* @param bool $allowIfEqualGrantedDeniedDecisions Whether to grant access if result are equals
66+
* @param iterable<mixed, VoterInterface> $voters An array or an iterator of VoterInterface instances
67+
* @param AccessDecisionStrategyInterface|null $strategy The vote strategy
4468
*
4569
* @throws \InvalidArgumentException
4670
*/
47-
public function __construct(iterable $voters = [], string $strategy = self::STRATEGY_AFFIRMATIVE, bool $allowIfAllAbstainDecisions = false, bool $allowIfEqualGrantedDeniedDecisions = true)
71+
public function __construct(iterable $voters = [], /* AccessDecisionStrategyInterface */ $strategy = null)
4872
{
49-
$strategyMethod = 'decide'.ucfirst($strategy);
50-
if ('' === $strategy || !\is_callable([$this, $strategyMethod])) {
51-
throw new \InvalidArgumentException(sprintf('The strategy "%s" is not supported.', $strategy));
73+
$this->voters = $voters;
74+
if (\is_string($strategy)) {
75+
trigger_deprecation('symfony/security-core', '5.4', 'Passing the access decision strategy as a string is deprecated, pass an instance of "%s" instead.', AccessDecisionStrategyInterface::class);
76+
$allowIfAllAbstainDecisions = 3 <= \func_num_args() && func_get_arg(2);
77+
$allowIfEqualGrantedDeniedDecisions = 4 > \func_num_args() || func_get_arg(3);
78+
79+
$strategy = $this->createStrategy($strategy, $allowIfAllAbstainDecisions, $allowIfEqualGrantedDeniedDecisions);
80+
} elseif (null !== $strategy && !$strategy instanceof AccessDecisionStrategyInterface) {
81+
throw new \TypeError(sprintf('"%s": Parameter #2 ($strategy) is expected to be an instance of "%s" or null, "%s" given.', __METHOD__, AccessDecisionStrategyInterface::class, get_debug_type($strategy)));
5282
}
5383

54-
$this->voters = $voters;
55-
$this->strategy = $strategyMethod;
56-
$this->allowIfAllAbstainDecisions = $allowIfAllAbstainDecisions;
57-
$this->allowIfEqualGrantedDeniedDecisions = $allowIfEqualGrantedDeniedDecisions;
84+
$this->strategy = $strategy ?? new AffirmativeStrategy();
5885
}
5986

6087
/**
@@ -71,145 +98,50 @@ public function decide(TokenInterface $token, array $attributes, $object = null/
7198
throw new InvalidArgumentException(sprintf('Passing more than one Security attribute to "%s()" is not supported.', __METHOD__));
7299
}
73100

74-
return $this->{$this->strategy}($token, $attributes, $object);
101+
return $this->strategy->decide(
102+
$this->collectResults($token, $attributes, $object)
103+
);
75104
}
76105

77106
/**
78-
* Grants access if any voter returns an affirmative response.
107+
* @param mixed $object
79108
*
80-
* If all voters abstained from voting, the decision will be based on the
81-
* allowIfAllAbstainDecisions property value (defaults to false).
109+
* @return \Traversable<int, int>
82110
*/
83-
private function decideAffirmative(TokenInterface $token, array $attributes, $object = null): bool
111+
private function collectResults(TokenInterface $token, array $attributes, $object): \Traversable
84112
{
85-
$deny = 0;
86113
foreach ($this->getVoters($attributes, $object) as $voter) {
87114
$result = $voter->vote($token, $object, $attributes);
88-
89-
if (VoterInterface::ACCESS_GRANTED === $result) {
90-
return true;
91-
}
92-
93-
if (VoterInterface::ACCESS_DENIED === $result) {
94-
++$deny;
95-
} elseif (VoterInterface::ACCESS_ABSTAIN !== $result) {
115+
if (!\is_int($result) || !(self::VALID_VOTES[$result] ?? false)) {
96116
trigger_deprecation('symfony/security-core', '5.3', 'Returning "%s" in "%s::vote()" is deprecated, return one of "%s" constants: "ACCESS_GRANTED", "ACCESS_DENIED" or "ACCESS_ABSTAIN".', var_export($result, true), get_debug_type($voter), VoterInterface::class);
97117
}
98-
}
99118

100-
if ($deny > 0) {
101-
return false;
119+
yield $result;
102120
}
103-
104-
return $this->allowIfAllAbstainDecisions;
105121
}
106122

107123
/**
108-
* Grants access if there is consensus of granted against denied responses.
109-
*
110-
* Consensus means majority-rule (ignoring abstains) rather than unanimous
111-
* agreement (ignoring abstains). If you require unanimity, see
112-
* UnanimousBased.
113-
*
114-
* If there were an equal number of grant and deny votes, the decision will
115-
* be based on the allowIfEqualGrantedDeniedDecisions property value
116-
* (defaults to true).
117-
*
118-
* If all voters abstained from voting, the decision will be based on the
119-
* allowIfAllAbstainDecisions property value (defaults to false).
124+
* @throws \InvalidArgumentException if the $strategy is invalid
120125
*/
121-
private function decideConsensus(TokenInterface $token, array $attributes, $object = null): bool
126+
private function createStrategy(string $strategy, bool $allowIfAllAbstainDecisions, bool $allowIfEqualGrantedDeniedDecisions): AccessDecisionStrategyInterface
122127
{
123-
$grant = 0;
124-
$deny = 0;
125-
foreach ($this->getVoters($attributes, $object) as $voter) {
126-
$result = $voter->vote($token, $object, $attributes);
127-
128-
if (VoterInterface::ACCESS_GRANTED === $result) {
129-
++$grant;
130-
} elseif (VoterInterface::ACCESS_DENIED === $result) {
131-
++$deny;
132-
} elseif (VoterInterface::ACCESS_ABSTAIN !== $result) {
133-
trigger_deprecation('symfony/security-core', '5.3', 'Returning "%s" in "%s::vote()" is deprecated, return one of "%s" constants: "ACCESS_GRANTED", "ACCESS_DENIED" or "ACCESS_ABSTAIN".', var_export($result, true), get_debug_type($voter), VoterInterface::class);
134-
}
135-
}
136-
137-
if ($grant > $deny) {
138-
return true;
139-
}
140-
141-
if ($deny > $grant) {
142-
return false;
128+
switch ($strategy) {
129+
case self::STRATEGY_AFFIRMATIVE:
130+
return new AffirmativeStrategy($allowIfAllAbstainDecisions);
131+
case self::STRATEGY_CONSENSUS:
132+
return new ConsensusStrategy($allowIfAllAbstainDecisions, $allowIfEqualGrantedDeniedDecisions);
133+
case self::STRATEGY_UNANIMOUS:
134+
return new UnanimousStrategy($allowIfAllAbstainDecisions);
135+
case self::STRATEGY_PRIORITY:
136+
return new PriorityStrategy($allowIfAllAbstainDecisions);
143137
}
144138

145-
if ($grant > 0) {
146-
return $this->allowIfEqualGrantedDeniedDecisions;
147-
}
148-
149-
return $this->allowIfAllAbstainDecisions;
150-
}
151-
152-
/**
153-
* Grants access if only grant (or abstain) votes were received.
154-
*
155-
* If all voters abstained from voting, the decision will be based on the
156-
* allowIfAllAbstainDecisions property value (defaults to false).
157-
*/
158-
private function decideUnanimous(TokenInterface $token, array $attributes, $object = null): bool
159-
{
160-
$grant = 0;
161-
foreach ($this->getVoters($attributes, $object) as $voter) {
162-
foreach ($attributes as $attribute) {
163-
$result = $voter->vote($token, $object, [$attribute]);
164-
165-
if (VoterInterface::ACCESS_DENIED === $result) {
166-
return false;
167-
}
168-
169-
if (VoterInterface::ACCESS_GRANTED === $result) {
170-
++$grant;
171-
} elseif (VoterInterface::ACCESS_ABSTAIN !== $result) {
172-
trigger_deprecation('symfony/security-core', '5.3', 'Returning "%s" in "%s::vote()" is deprecated, return one of "%s" constants: "ACCESS_GRANTED", "ACCESS_DENIED" or "ACCESS_ABSTAIN".', var_export($result, true), get_debug_type($voter), VoterInterface::class);
173-
}
174-
}
175-
}
176-
177-
// no deny votes
178-
if ($grant > 0) {
179-
return true;
180-
}
181-
182-
return $this->allowIfAllAbstainDecisions;
139+
throw new \InvalidArgumentException(sprintf('The strategy "%s" is not supported.', $strategy));
183140
}
184141

185142
/**
186-
* Grant or deny access depending on the first voter that does not abstain.
187-
* The priority of voters can be used to overrule a decision.
188-
*
189-
* If all voters abstained from voting, the decision will be based on the
190-
* allowIfAllAbstainDecisions property value (defaults to false).
143+
* @return iterable<mixed, VoterInterface>
191144
*/
192-
private function decidePriority(TokenInterface $token, array $attributes, $object = null)
193-
{
194-
foreach ($this->getVoters($attributes, $object) as $voter) {
195-
$result = $voter->vote($token, $object, $attributes);
196-
197-
if (VoterInterface::ACCESS_GRANTED === $result) {
198-
return true;
199-
}
200-
201-
if (VoterInterface::ACCESS_DENIED === $result) {
202-
return false;
203-
}
204-
205-
if (VoterInterface::ACCESS_ABSTAIN !== $result) {
206-
trigger_deprecation('symfony/security-core', '5.3', 'Returning "%s" in "%s::vote()" is deprecated, return one of "%s" constants: "ACCESS_GRANTED", "ACCESS_DENIED" or "ACCESS_ABSTAIN".', var_export($result, true), get_debug_type($voter), VoterInterface::class);
207-
}
208-
}
209-
210-
return $this->allowIfAllAbstainDecisions;
211-
}
212-
213145
private function getVoters(array $attributes, $object = null): iterable
214146
{
215147
$keyAttributes = [];
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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\Component\Security\Core\Authorization\Strategy;
13+
14+
/**
15+
* A strategy for turning a stream of votes into a final decision.
16+
*
17+
* @author Alexander M. Turek <me@derrabus.de>
18+
*/
19+
interface AccessDecisionStrategyInterface
20+
{
21+
/**
22+
* @param \Traversable<int> $results
23+
*/
24+
public function decide(\Traversable $results): bool;
25+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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\Component\Security\Core\Authorization\Strategy;
13+
14+
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
15+
16+
/**
17+
* Grants access if any voter returns an affirmative response.
18+
*
19+
* If all voters abstained from voting, the decision will be based on the
20+
* allowIfAllAbstainDecisions property value (defaults to false).
21+
*
22+
* @author Fabien Potencier <fabien@symfony.com>
23+
* @author Alexander M. Turek <me@derrabus.de>
24+
*/
25+
final class AffirmativeStrategy implements AccessDecisionStrategyInterface, \Stringable
26+
{
27+
/**
28+
* @var bool
29+
*/
30+
private $allowIfAllAbstainDecisions;
31+
32+
public function __construct(bool $allowIfAllAbstainDecisions = false)
33+
{
34+
$this->allowIfAllAbstainDecisions = $allowIfAllAbstainDecisions;
35+
}
36+
37+
/**
38+
* {@inheritdoc}
39+
*/
40+
public function decide(\Traversable $results): bool
41+
{
42+
$deny = 0;
43+
foreach ($results as $result) {
44+
if (VoterInterface::ACCESS_GRANTED === $result) {
45+
return true;
46+
}
47+
48+
if (VoterInterface::ACCESS_DENIED === $result) {
49+
++$deny;
50+
}
51+
}
52+
53+
if ($deny > 0) {
54+
return false;
55+
}
56+
57+
return $this->allowIfAllAbstainDecisions;
58+
}
59+
60+
public function __toString(): string
61+
{
62+
return 'affirmative';
63+
}
64+
}

0 commit comments

Comments
 (0)