Skip to content

Commit cd31fba

Browse files
committed
Add Access Control component with strategies and voters
Introduce the new Access Control component in Symfony, providing core access decision-making and voting mechanisms. This includes support for affirmative, unanimous, and consensus strategies, along with role-based and expression-based voters. Tests and examples included to validate behavior.
1 parent 4dcb217 commit cd31fba

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+1814
-0
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"symfony/polyfill-uuid": "^1.15"
5959
},
6060
"replace": {
61+
"symfony/access-control": "self.version",
6162
"symfony/asset": "self.version",
6263
"symfony/asset-mapper": "self.version",
6364
"symfony/browser-kit": "self.version",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/Tests export-ignore
2+
/phpunit.xml.dist export-ignore
3+
/.git* export-ignore
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
vendor/
2+
composer.lock
3+
phpunit.xml
4+
Tests/Fixtures/var/
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<?php
2+
3+
namespace Symfony\Component\AccessControl;
4+
5+
use Symfony\Component\AccessControl\Event\AccessDecisionEvent;
6+
use Symfony\Component\AccessControl\Event\VoteEvent;
7+
use Symfony\Component\AccessControl\Exception\InvalidStrategyException;
8+
use Symfony\Component\AccessControl\Strategy\AffirmativeStrategy;
9+
use Symfony\Component\AccessControl\Strategy\StrategyInterface;
10+
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
11+
12+
/**
13+
* @experimental
14+
*/
15+
class AccessControlManager implements AccessControlManagerInterface
16+
{
17+
private readonly string $defaultStrategy;
18+
19+
/**
20+
* @var array<string, StrategyInterface>
21+
*/
22+
private readonly array $strategies;
23+
24+
/**
25+
* @var array<string, array<array-key, bool>>
26+
*/
27+
private array $votersCacheAttributes = [];
28+
/**
29+
* @var array<string, array<array-key, bool>>
30+
*/
31+
private mixed $votersCacheSubject = [];
32+
33+
/**
34+
* @param iterable<StrategyInterface> $strategies
35+
* @param iterable<VoterInterface> $voters
36+
*/
37+
public function __construct(
38+
iterable $strategies,
39+
private readonly iterable $voters,
40+
?string $defaultStrategy = null,
41+
private readonly ?EventDispatcherInterface $dispatcher = null
42+
) {
43+
$namedStrategies = [];
44+
foreach ($strategies as $strategy) {
45+
$namedStrategies[$strategy->getName()] = $strategy;
46+
}
47+
if (\count($namedStrategies) === 0) {
48+
$namedStrategies['affirmative'] = new AffirmativeStrategy();
49+
$defaultStrategy = 'affirmative';
50+
}
51+
if ($defaultStrategy === null) {
52+
$defaultStrategy = array_key_first($namedStrategies);
53+
assert($defaultStrategy !== null, 'The default strategy cannot be null.');
54+
}
55+
$this->defaultStrategy = $defaultStrategy;
56+
$this->strategies = $namedStrategies;
57+
}
58+
59+
public function decide(AccessRequest $accessRequest, ?string $strategy = null): AccessDecision
60+
{
61+
$strategy = $strategy ?? $this->defaultStrategy;
62+
if (!isset($this->strategies[$strategy])) {
63+
throw new InvalidStrategyException(sprintf('Strategy "%s" is not registered. Valid strategies are: %s', $strategy, implode(', ', array_keys($this->strategies))));
64+
}
65+
$votes = [];
66+
foreach ($this->getVoters($accessRequest) as $voter) {
67+
$vote = $voter->vote($accessRequest);
68+
$votes[] = $vote;
69+
$this->dispatcher?->dispatch(new VoteEvent($voter, $accessRequest, $vote));
70+
}
71+
72+
$accessDecision = $this->strategies[$strategy]->evaluate($accessRequest, $votes);
73+
if ($accessDecision->decision !== DecisionVote::ACCESS_ABSTAIN) {
74+
$this->dispatcher?->dispatch(new AccessDecisionEvent($accessRequest, $accessDecision));
75+
return $accessDecision;
76+
}
77+
78+
$accessDecision = AccessDecision::deny($accessRequest, $votes, $accessDecision->reason);
79+
if ($accessRequest->allowIfAllAbstainOrTie) {
80+
$accessDecision = AccessDecision::grant($accessRequest, $votes, $accessDecision->reason);
81+
}
82+
83+
$this->dispatcher?->dispatch(new AccessDecisionEvent($accessRequest, $accessDecision));
84+
85+
return $accessDecision;
86+
}
87+
88+
/**
89+
* @return iterable<VoterInterface>
90+
*/
91+
private function getVoters(AccessRequest $accessRequest): iterable
92+
{
93+
$keyAttribute = \is_object($accessRequest->attribute) ? $accessRequest->attribute::class : get_debug_type($accessRequest->attribute);
94+
$keySubject = \is_object($accessRequest->subject) ? $accessRequest->subject::class : get_debug_type($accessRequest->subject);
95+
foreach ($this->voters as $key => $voter) {
96+
if (!isset($this->votersCacheAttributes[$keyAttribute][$key])) {
97+
$this->votersCacheAttributes[$keyAttribute][$key] = $voter->supportsAttribute($accessRequest->attribute);
98+
}
99+
if (!$this->votersCacheAttributes[$keyAttribute][$key]) {
100+
continue;
101+
}
102+
103+
if (!isset($this->votersCacheSubject[$keySubject][$key])) {
104+
$this->votersCacheSubject[$keySubject][$key] = $voter->supportsSubject($accessRequest->subject);
105+
}
106+
if (!$this->votersCacheSubject[$keySubject][$key]) {
107+
continue;
108+
}
109+
yield $voter;
110+
}
111+
}
112+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
namespace Symfony\Component\AccessControl;
4+
5+
/**
6+
* @experimental
7+
*/
8+
interface AccessControlManagerInterface
9+
{
10+
public function decide(AccessRequest $accessRequest, ?string $strategy = null): AccessDecision;
11+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
namespace Symfony\Component\AccessControl;
4+
5+
/**
6+
* @experimental
7+
*/
8+
readonly class AccessDecision
9+
{
10+
/**
11+
* @param iterable<VoterOutcome> $votes
12+
*/
13+
public function __construct(
14+
public AccessRequest $accessRequest,
15+
public DecisionVote $decision,
16+
public iterable $votes,
17+
public ?string $reason = null,
18+
) {
19+
}
20+
21+
/**
22+
* @param iterable<VoterOutcome> $votes
23+
*/
24+
public static function grant(AccessRequest $accessRequest, iterable $votes, ?string $reason = null): self
25+
{
26+
return new self($accessRequest, DecisionVote::ACCESS_GRANTED, $votes, $reason);
27+
}
28+
29+
/**
30+
* @param iterable<VoterOutcome> $votes
31+
*/
32+
public static function deny(AccessRequest $accessRequest, iterable $votes, ?string $reason = null): self
33+
{
34+
return new self($accessRequest, DecisionVote::ACCESS_DENIED, $votes, $reason);
35+
}
36+
37+
public static function abstain(AccessRequest $accessRequest, iterable $votes, ?string $reason = null): self
38+
{
39+
return new self($accessRequest, DecisionVote::ACCESS_ABSTAIN, $votes, $reason);
40+
}
41+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace Symfony\Component\AccessControl;
4+
5+
use Symfony\Component\ExpressionLanguage\Expression;
6+
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
7+
8+
/**
9+
* @experimental
10+
*/
11+
readonly class AccessRequest
12+
{
13+
/**
14+
* @param array<array-key, mixed> $metadata
15+
*/
16+
public function __construct(
17+
public mixed $attribute,
18+
public mixed $subject = null,
19+
public array $metadata = [],
20+
public bool $allowIfAllAbstainOrTie = false,
21+
) {
22+
}
23+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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\AccessControl\Attribute;
13+
14+
/**
15+
* @experimental
16+
*/
17+
#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)]
18+
readonly class AccessPolicy
19+
{
20+
/**
21+
* @param array<array-key, mixed> $
22+
*/
23+
public function __construct(
24+
public mixed $attribute,
25+
public mixed $subject = null,
26+
public ?string $strategy = null,
27+
public array $metadata = [],
28+
public bool $allowIfAllAbstain = false,
29+
public string $message = 'Access Denied.',
30+
public ?int $statusCode = null,
31+
public ?int $exceptionCode = null,
32+
) {
33+
}
34+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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\AccessControl\Attribute;
13+
14+
/**
15+
* @experimental
16+
*/
17+
#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)]
18+
readonly class All
19+
{
20+
/**
21+
* @param list<AccessPolicy> $accessPolicies
22+
*/
23+
public function __construct(
24+
public array $accessPolicies,
25+
public string $message = 'Access Denied.',
26+
public ?int $statusCode = null,
27+
public ?int $exceptionCode = null,
28+
) {
29+
}
30+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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\AccessControl\Attribute;
13+
14+
/**
15+
* @experimental
16+
*/
17+
#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)]
18+
readonly class AtLeastOneOf
19+
{
20+
/**
21+
* @param list<AccessPolicy> $accessPolicies
22+
*/
23+
public function __construct(
24+
public array $accessPolicies,
25+
) {
26+
}
27+
}

0 commit comments

Comments
 (0)