From aec8f2c810a4cb2067dac6281fd5176a1a81e813 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Mon, 13 Jan 2025 15:01:56 +0100 Subject: [PATCH 1/3] 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. --- composer.json | 1 + .../Component/AccessControl/.gitattributes | 3 + .../Component/AccessControl/.gitignore | 4 + .../AccessControl/AccessControlManager.php | 112 ++++++++++++++++++ .../AccessControlManagerInterface.php | 11 ++ .../AccessControl/AccessDecision.php | 41 +++++++ .../Component/AccessControl/AccessRequest.php | 23 ++++ .../AccessControl/Attribute/AccessPolicy.php | 34 ++++++ .../Component/AccessControl/Attribute/All.php | 30 +++++ .../AccessControl/Attribute/AtLeastOneOf.php | 27 +++++ .../Component/AccessControl/CHANGELOG.md | 7 ++ .../Component/AccessControl/DecisionVote.php | 13 ++ .../Event/AccessDecisionEvent.php | 21 ++++ .../AccessControl/Event/VoteEvent.php | 21 ++++ .../Exception/InvalidStrategyException.php | 11 ++ .../AccessControl/ExpressionLanguage.php | 37 ++++++ .../ExpressionLanguageProvider.php | 31 +++++ src/Symfony/Component/AccessControl/LICENSE | 19 +++ .../Listener/AccessPolicyListener.php | 98 +++++++++++++++ .../AccessControl/Listener/AllListener.php | 100 ++++++++++++++++ .../Listener/AtLeastOneOfListener.php | 102 ++++++++++++++++ src/Symfony/Component/AccessControl/README.md | 16 +++ .../Strategy/AffirmativeStrategy.php | 48 ++++++++ .../Strategy/ConsensusStrategy.php | 51 ++++++++ .../Strategy/StrategyInterface.php | 20 ++++ .../Strategy/UnanimousStrategy.php | 48 ++++++++ .../Tests/AffirmativeStrategyTest.php | 111 +++++++++++++++++ .../Tests/ConsensusStrategyTest.php | 51 ++++++++ .../AccessControl/Tests/EventsTest.php | 27 +++++ .../Tests/FakeEventDispatcher.php | 16 +++ .../AccessControl/Tests/FakeTokenStorage.php | 21 ++++ .../AccessControl/Tests/FakeUser.php | 30 +++++ .../AccessControl/Tests/FakeUserWithRole.php | 30 +++++ .../AccessControl/Tests/StrategyTestCase.php | 81 +++++++++++++ .../Tests/UnanimousStrategyTest.php | 53 +++++++++ .../Voter/ABAC/AuthenticatedVoter.php | 80 +++++++++++++ .../Voter/ABAC/AuthenticationState.php | 38 ++++++ .../Voter/Expression/ExpressionVoter.php | 93 +++++++++++++++ .../Voter/RBAC/RoleHierarchyVoter.php | 42 +++++++ .../AccessControl/Voter/RBAC/RoleVoter.php | 69 +++++++++++ .../Voter/RBAC/UserWithRoleInterface.php | 27 +++++ .../AccessControl/VoterInterface.php | 24 ++++ .../Component/AccessControl/VoterOutcome.php | 31 +++++ .../Component/AccessControl/composer.json | 31 +++++ .../Component/AccessControl/phpunit.xml.dist | 30 +++++ 45 files changed, 1814 insertions(+) create mode 100644 src/Symfony/Component/AccessControl/.gitattributes create mode 100644 src/Symfony/Component/AccessControl/.gitignore create mode 100644 src/Symfony/Component/AccessControl/AccessControlManager.php create mode 100644 src/Symfony/Component/AccessControl/AccessControlManagerInterface.php create mode 100644 src/Symfony/Component/AccessControl/AccessDecision.php create mode 100644 src/Symfony/Component/AccessControl/AccessRequest.php create mode 100644 src/Symfony/Component/AccessControl/Attribute/AccessPolicy.php create mode 100644 src/Symfony/Component/AccessControl/Attribute/All.php create mode 100644 src/Symfony/Component/AccessControl/Attribute/AtLeastOneOf.php create mode 100644 src/Symfony/Component/AccessControl/CHANGELOG.md create mode 100644 src/Symfony/Component/AccessControl/DecisionVote.php create mode 100644 src/Symfony/Component/AccessControl/Event/AccessDecisionEvent.php create mode 100644 src/Symfony/Component/AccessControl/Event/VoteEvent.php create mode 100644 src/Symfony/Component/AccessControl/Exception/InvalidStrategyException.php create mode 100644 src/Symfony/Component/AccessControl/ExpressionLanguage.php create mode 100644 src/Symfony/Component/AccessControl/ExpressionLanguageProvider.php create mode 100644 src/Symfony/Component/AccessControl/LICENSE create mode 100644 src/Symfony/Component/AccessControl/Listener/AccessPolicyListener.php create mode 100644 src/Symfony/Component/AccessControl/Listener/AllListener.php create mode 100644 src/Symfony/Component/AccessControl/Listener/AtLeastOneOfListener.php create mode 100644 src/Symfony/Component/AccessControl/README.md create mode 100644 src/Symfony/Component/AccessControl/Strategy/AffirmativeStrategy.php create mode 100644 src/Symfony/Component/AccessControl/Strategy/ConsensusStrategy.php create mode 100644 src/Symfony/Component/AccessControl/Strategy/StrategyInterface.php create mode 100644 src/Symfony/Component/AccessControl/Strategy/UnanimousStrategy.php create mode 100644 src/Symfony/Component/AccessControl/Tests/AffirmativeStrategyTest.php create mode 100644 src/Symfony/Component/AccessControl/Tests/ConsensusStrategyTest.php create mode 100644 src/Symfony/Component/AccessControl/Tests/EventsTest.php create mode 100644 src/Symfony/Component/AccessControl/Tests/FakeEventDispatcher.php create mode 100644 src/Symfony/Component/AccessControl/Tests/FakeTokenStorage.php create mode 100644 src/Symfony/Component/AccessControl/Tests/FakeUser.php create mode 100644 src/Symfony/Component/AccessControl/Tests/FakeUserWithRole.php create mode 100644 src/Symfony/Component/AccessControl/Tests/StrategyTestCase.php create mode 100644 src/Symfony/Component/AccessControl/Tests/UnanimousStrategyTest.php create mode 100644 src/Symfony/Component/AccessControl/Voter/ABAC/AuthenticatedVoter.php create mode 100644 src/Symfony/Component/AccessControl/Voter/ABAC/AuthenticationState.php create mode 100644 src/Symfony/Component/AccessControl/Voter/Expression/ExpressionVoter.php create mode 100644 src/Symfony/Component/AccessControl/Voter/RBAC/RoleHierarchyVoter.php create mode 100644 src/Symfony/Component/AccessControl/Voter/RBAC/RoleVoter.php create mode 100644 src/Symfony/Component/AccessControl/Voter/RBAC/UserWithRoleInterface.php create mode 100644 src/Symfony/Component/AccessControl/VoterInterface.php create mode 100644 src/Symfony/Component/AccessControl/VoterOutcome.php create mode 100644 src/Symfony/Component/AccessControl/composer.json create mode 100644 src/Symfony/Component/AccessControl/phpunit.xml.dist diff --git a/composer.json b/composer.json index b6099e895494..2e6528a13c36 100644 --- a/composer.json +++ b/composer.json @@ -58,6 +58,7 @@ "symfony/polyfill-uuid": "^1.15" }, "replace": { + "symfony/access-control": "self.version", "symfony/asset": "self.version", "symfony/asset-mapper": "self.version", "symfony/browser-kit": "self.version", diff --git a/src/Symfony/Component/AccessControl/.gitattributes b/src/Symfony/Component/AccessControl/.gitattributes new file mode 100644 index 000000000000..14c3c3594042 --- /dev/null +++ b/src/Symfony/Component/AccessControl/.gitattributes @@ -0,0 +1,3 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.git* export-ignore diff --git a/src/Symfony/Component/AccessControl/.gitignore b/src/Symfony/Component/AccessControl/.gitignore new file mode 100644 index 000000000000..c7f1fdae683c --- /dev/null +++ b/src/Symfony/Component/AccessControl/.gitignore @@ -0,0 +1,4 @@ +vendor/ +composer.lock +phpunit.xml +Tests/Fixtures/var/ diff --git a/src/Symfony/Component/AccessControl/AccessControlManager.php b/src/Symfony/Component/AccessControl/AccessControlManager.php new file mode 100644 index 000000000000..4f48cf61143c --- /dev/null +++ b/src/Symfony/Component/AccessControl/AccessControlManager.php @@ -0,0 +1,112 @@ + + */ + private readonly array $strategies; + + /** + * @var array> + */ + private array $votersCacheAttributes = []; + /** + * @var array> + */ + private mixed $votersCacheSubject = []; + + /** + * @param iterable $strategies + * @param iterable $voters + */ + public function __construct( + iterable $strategies, + private readonly iterable $voters, + ?string $defaultStrategy = null, + private readonly ?EventDispatcherInterface $dispatcher = null + ) { + $namedStrategies = []; + foreach ($strategies as $strategy) { + $namedStrategies[$strategy->getName()] = $strategy; + } + if (\count($namedStrategies) === 0) { + $namedStrategies['affirmative'] = new AffirmativeStrategy(); + $defaultStrategy = 'affirmative'; + } + if ($defaultStrategy === null) { + $defaultStrategy = array_key_first($namedStrategies); + assert($defaultStrategy !== null, 'The default strategy cannot be null.'); + } + $this->defaultStrategy = $defaultStrategy; + $this->strategies = $namedStrategies; + } + + public function decide(AccessRequest $accessRequest, ?string $strategy = null): AccessDecision + { + $strategy = $strategy ?? $this->defaultStrategy; + if (!isset($this->strategies[$strategy])) { + throw new InvalidStrategyException(sprintf('Strategy "%s" is not registered. Valid strategies are: %s', $strategy, implode(', ', array_keys($this->strategies)))); + } + $votes = []; + foreach ($this->getVoters($accessRequest) as $voter) { + $vote = $voter->vote($accessRequest); + $votes[] = $vote; + $this->dispatcher?->dispatch(new VoteEvent($voter, $accessRequest, $vote)); + } + + $accessDecision = $this->strategies[$strategy]->evaluate($accessRequest, $votes); + if ($accessDecision->decision !== DecisionVote::ACCESS_ABSTAIN) { + $this->dispatcher?->dispatch(new AccessDecisionEvent($accessRequest, $accessDecision)); + return $accessDecision; + } + + $accessDecision = AccessDecision::deny($accessRequest, $votes, $accessDecision->reason); + if ($accessRequest->allowIfAllAbstainOrTie) { + $accessDecision = AccessDecision::grant($accessRequest, $votes, $accessDecision->reason); + } + + $this->dispatcher?->dispatch(new AccessDecisionEvent($accessRequest, $accessDecision)); + + return $accessDecision; + } + + /** + * @return iterable + */ + private function getVoters(AccessRequest $accessRequest): iterable + { + $keyAttribute = \is_object($accessRequest->attribute) ? $accessRequest->attribute::class : get_debug_type($accessRequest->attribute); + $keySubject = \is_object($accessRequest->subject) ? $accessRequest->subject::class : get_debug_type($accessRequest->subject); + foreach ($this->voters as $key => $voter) { + if (!isset($this->votersCacheAttributes[$keyAttribute][$key])) { + $this->votersCacheAttributes[$keyAttribute][$key] = $voter->supportsAttribute($accessRequest->attribute); + } + if (!$this->votersCacheAttributes[$keyAttribute][$key]) { + continue; + } + + if (!isset($this->votersCacheSubject[$keySubject][$key])) { + $this->votersCacheSubject[$keySubject][$key] = $voter->supportsSubject($accessRequest->subject); + } + if (!$this->votersCacheSubject[$keySubject][$key]) { + continue; + } + yield $voter; + } + } +} diff --git a/src/Symfony/Component/AccessControl/AccessControlManagerInterface.php b/src/Symfony/Component/AccessControl/AccessControlManagerInterface.php new file mode 100644 index 000000000000..35080429769f --- /dev/null +++ b/src/Symfony/Component/AccessControl/AccessControlManagerInterface.php @@ -0,0 +1,11 @@ + $votes + */ + public function __construct( + public AccessRequest $accessRequest, + public DecisionVote $decision, + public iterable $votes, + public ?string $reason = null, + ) { + } + + /** + * @param iterable $votes + */ + public static function grant(AccessRequest $accessRequest, iterable $votes, ?string $reason = null): self + { + return new self($accessRequest, DecisionVote::ACCESS_GRANTED, $votes, $reason); + } + + /** + * @param iterable $votes + */ + public static function deny(AccessRequest $accessRequest, iterable $votes, ?string $reason = null): self + { + return new self($accessRequest, DecisionVote::ACCESS_DENIED, $votes, $reason); + } + + public static function abstain(AccessRequest $accessRequest, iterable $votes, ?string $reason = null): self + { + return new self($accessRequest, DecisionVote::ACCESS_ABSTAIN, $votes, $reason); + } +} diff --git a/src/Symfony/Component/AccessControl/AccessRequest.php b/src/Symfony/Component/AccessControl/AccessRequest.php new file mode 100644 index 000000000000..7c72673fb47f --- /dev/null +++ b/src/Symfony/Component/AccessControl/AccessRequest.php @@ -0,0 +1,23 @@ + $metadata + */ + public function __construct( + public mixed $attribute, + public mixed $subject = null, + public array $metadata = [], + public bool $allowIfAllAbstainOrTie = false, + ) { + } +} diff --git a/src/Symfony/Component/AccessControl/Attribute/AccessPolicy.php b/src/Symfony/Component/AccessControl/Attribute/AccessPolicy.php new file mode 100644 index 000000000000..25ebc8c219d8 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Attribute/AccessPolicy.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AccessControl\Attribute; + +/** + * @experimental + */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] +readonly class AccessPolicy +{ + /** + * @param array $ + */ + public function __construct( + public mixed $attribute, + public mixed $subject = null, + public ?string $strategy = null, + public array $metadata = [], + public bool $allowIfAllAbstain = false, + public string $message = 'Access Denied.', + public ?int $statusCode = null, + public ?int $exceptionCode = null, + ) { + } +} diff --git a/src/Symfony/Component/AccessControl/Attribute/All.php b/src/Symfony/Component/AccessControl/Attribute/All.php new file mode 100644 index 000000000000..993435bc64c8 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Attribute/All.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AccessControl\Attribute; + +/** + * @experimental + */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] +readonly class All +{ + /** + * @param list $accessPolicies + */ + public function __construct( + public array $accessPolicies, + public string $message = 'Access Denied.', + public ?int $statusCode = null, + public ?int $exceptionCode = null, + ) { + } +} diff --git a/src/Symfony/Component/AccessControl/Attribute/AtLeastOneOf.php b/src/Symfony/Component/AccessControl/Attribute/AtLeastOneOf.php new file mode 100644 index 000000000000..a2798c764410 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Attribute/AtLeastOneOf.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AccessControl\Attribute; + +/** + * @experimental + */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] +readonly class AtLeastOneOf +{ + /** + * @param list $accessPolicies + */ + public function __construct( + public array $accessPolicies, + ) { + } +} diff --git a/src/Symfony/Component/AccessControl/CHANGELOG.md b/src/Symfony/Component/AccessControl/CHANGELOG.md new file mode 100644 index 000000000000..0f29770616c5 --- /dev/null +++ b/src/Symfony/Component/AccessControl/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +7.3 +--- + + * Add the component as experimental diff --git a/src/Symfony/Component/AccessControl/DecisionVote.php b/src/Symfony/Component/AccessControl/DecisionVote.php new file mode 100644 index 000000000000..df9640d13661 --- /dev/null +++ b/src/Symfony/Component/AccessControl/DecisionVote.php @@ -0,0 +1,13 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AccessControl; + +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage as BaseExpressionLanguage; + +if (!class_exists(BaseExpressionLanguage::class)) { + throw new \LogicException(\sprintf('The "%s" class requires the "ExpressionLanguage" component. Try running "composer require symfony/expression-language".', ExpressionLanguage::class)); +} + +// Help opcache.preload discover always-needed symbols +class_exists(ExpressionLanguageProvider::class); + + +/** + * @experimental + */ +class ExpressionLanguage extends BaseExpressionLanguage +{ + public function __construct(?CacheItemPoolInterface $cache = null, array $providers = []) + { + // prepend the default provider to let users override it easily + array_unshift($providers, new ExpressionLanguageProvider()); + + parent::__construct($cache, $providers); + } +} diff --git a/src/Symfony/Component/AccessControl/ExpressionLanguageProvider.php b/src/Symfony/Component/AccessControl/ExpressionLanguageProvider.php new file mode 100644 index 000000000000..e5c0e8c7d479 --- /dev/null +++ b/src/Symfony/Component/AccessControl/ExpressionLanguageProvider.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AccessControl; + +use Symfony\Component\ExpressionLanguage\ExpressionFunction; +use Symfony\Component\ExpressionLanguage\ExpressionFunctionProviderInterface; + + +/** + * @experimental + */ +class ExpressionLanguageProvider implements ExpressionFunctionProviderInterface +{ + public function getFunctions(): array + { + return [ + new ExpressionFunction('is_authenticated', static fn () => '$token && $trust_resolver->isAuthenticated($token)', static fn (array $variables) => $variables['token'] && $variables['trust_resolver']->isAuthenticated($variables['token'])), + new ExpressionFunction('is_fully_authenticated', static fn () => '$token && $trust_resolver->isFullFledged($token)', static fn (array $variables) => $variables['token'] && $variables['trust_resolver']->isFullFledged($variables['token'])), + new ExpressionFunction('is_remember_me', static fn () => '$token && $trust_resolver->isRememberMe($token)', static fn (array $variables) => $variables['token'] && $variables['trust_resolver']->isRememberMe($variables['token'])), + ]; + } +} diff --git a/src/Symfony/Component/AccessControl/LICENSE b/src/Symfony/Component/AccessControl/LICENSE new file mode 100644 index 000000000000..3ed9f412ce53 --- /dev/null +++ b/src/Symfony/Component/AccessControl/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/AccessControl/Listener/AccessPolicyListener.php b/src/Symfony/Component/AccessControl/Listener/AccessPolicyListener.php new file mode 100644 index 000000000000..385e0bffd2e1 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Listener/AccessPolicyListener.php @@ -0,0 +1,98 @@ + ['onKernelControllerArguments', 20], + ConsoleEvents::COMMAND => ['onConsoleCommand', 20], + ]; + } + + public function onConsoleCommand(ConsoleCommandEvent $event): void + { + $command = $event->getCommand(); + if ($command === null) { + return; + } + $reflectionClass = new \ReflectionClass($command); + $attributes = $reflectionClass->getAttributes(AccessPolicy::class); + + foreach ($attributes as $attribute) { + $this->processAttribute( + $attribute->newInstance(), + [ + 'input' => $event->getInput(), + 'output' => $event->getOutput() + ] + ); + } + } + + public function onKernelControllerArguments(ControllerArgumentsEvent $event): void + { + /** @var AccessPolicy[] $attributes */ + if (!\is_array($attributes = $event->getAttributes()[AccessPolicy::class] ?? null)) { + return; + } + + foreach ($attributes as $attribute) { + $this->processAttribute( + $attribute, + [ + 'request' => $event->getRequest(), + 'args' => $event->getArguments(), + ] + ); + } + } + + private function processAttribute(AccessPolicy $attribute, array $metadata): void + { + $accessRequest = new AccessRequest( + $attribute->attribute, + $attribute->subject, + [ + ...$attribute->metadata, + ...$metadata, + ], + $attribute->allowIfAllAbstain + ); + $accessDecision = $this->accessControlManager->decide($accessRequest, $attribute->strategy); + + if ($accessDecision->decision !== DecisionVote::ACCESS_GRANTED) { + if ($statusCode = $attribute->statusCode) { + throw new HttpException($statusCode, $attribute->message, code: $attribute->exceptionCode ?? 0); + } + + throw new AccessDeniedException( + $attribute->message, + null, + $attribute->exceptionCode ?? 403 + ); + } + } +} diff --git a/src/Symfony/Component/AccessControl/Listener/AllListener.php b/src/Symfony/Component/AccessControl/Listener/AllListener.php new file mode 100644 index 000000000000..64d0977f5a55 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Listener/AllListener.php @@ -0,0 +1,100 @@ + ['onKernelControllerArguments', 20], + ConsoleEvents::COMMAND => ['onConsoleCommand', 20], + ]; + } + + public function onConsoleCommand(ConsoleCommandEvent $event): void + { + $command = $event->getCommand(); + if ($command === null) { + return; + } + $reflectionClass = new \ReflectionClass($command); + $attributes = $reflectionClass->getAttributes(All::class); + + foreach ($attributes as $attribute) { + $this->processAttribute( + $attribute->newInstance(), + [ + 'input' => $event->getInput(), + 'output' => $event->getOutput() + ] + ); + } + } + + public function onKernelControllerArguments(ControllerArgumentsEvent $event): void + { + /** @var All[] $attributes */ + if (!\is_array($attributes = $event->getAttributes()[All::class] ?? null)) { + return; + } + + foreach ($attributes as $attribute) { + $this->processAttribute( + $attribute, + [ + 'request' => $event->getRequest(), + 'args' => $event->getArguments(), + ] + ); + } + } + + private function processAttribute(All $attribute, array $metadata): void + { + foreach ($attribute->accessPolicies as $accessPolicy) { + $accessRequest = new AccessRequest( + $accessPolicy->attribute, + $accessPolicy->subject, + [ + ...$accessPolicy->metadata, + ...$metadata, + ], + $accessPolicy->allowIfAllAbstain + ); + $accessDecision = $this->accessControlManager->decide($accessRequest, $accessPolicy->strategy); + + if ($accessDecision->decision !== DecisionVote::ACCESS_GRANTED) { + if ($statusCode = $attribute->statusCode) { + throw new HttpException($statusCode, $attribute->message, code: $attribute->exceptionCode ?? 0); + } + + throw new AccessDeniedException( + $attribute->message, + null, + $attribute->exceptionCode ?? 403 + ); + } + } + } +} diff --git a/src/Symfony/Component/AccessControl/Listener/AtLeastOneOfListener.php b/src/Symfony/Component/AccessControl/Listener/AtLeastOneOfListener.php new file mode 100644 index 000000000000..b1329169e2ae --- /dev/null +++ b/src/Symfony/Component/AccessControl/Listener/AtLeastOneOfListener.php @@ -0,0 +1,102 @@ + ['onKernelControllerArguments', 20], + ConsoleEvents::COMMAND => ['onConsoleCommand', 20], + ]; + } + + public function onConsoleCommand(ConsoleCommandEvent $event): void + { + $command = $event->getCommand(); + if ($command === null) { + return; + } + $reflectionClass = new \ReflectionClass($command); + $attributes = $reflectionClass->getAttributes(All::class); + + foreach ($attributes as $attribute) { + $this->processAttribute( + $attribute->newInstance(), + [ + 'input' => $event->getInput(), + 'output' => $event->getOutput() + ] + ); + } + } + + public function onKernelControllerArguments(ControllerArgumentsEvent $event): void + { + /** @var All[] $attributes */ + if (!\is_array($attributes = $event->getAttributes()[All::class] ?? null)) { + return; + } + + foreach ($attributes as $attribute) { + $this->processAttribute( + $attribute, + [ + 'request' => $event->getRequest(), + 'args' => $event->getArguments(), + ] + ); + } + } + + private function processAttribute(All $attribute, array $metadata): void + { + foreach ($attribute->accessPolicies as $accessPolicy) { + $accessRequest = new AccessRequest( + $accessPolicy->attribute, + $accessPolicy->subject, + [ + ...$accessPolicy->metadata, + ...$metadata, + ], + $accessPolicy->allowIfAllAbstain + ); + $accessDecision = $this->accessControlManager->decide($accessRequest, $accessPolicy->strategy); + + if ($accessDecision->decision === DecisionVote::ACCESS_GRANTED) { + return; + } + } + + if ($statusCode = $attribute->statusCode) { + throw new HttpException($statusCode, $attribute->message, code: $attribute->exceptionCode ?? 0); + } + + throw new AccessDeniedException( + $attribute->message, + null, + $attribute->exceptionCode ?? 403 + ); + } +} diff --git a/src/Symfony/Component/AccessControl/README.md b/src/Symfony/Component/AccessControl/README.md new file mode 100644 index 000000000000..21118b83c556 --- /dev/null +++ b/src/Symfony/Component/AccessControl/README.md @@ -0,0 +1,16 @@ +AssetMapper Component +===================== + +The AssetMapper component allows you to expose directories of assets that are +then moved to a public directory with digested (i.e. versioned) filenames. It +also allows you to dump an [importmap](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) +to allow writing modern JavaScript without a build system. + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/frontend/asset_mapper.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/AccessControl/Strategy/AffirmativeStrategy.php b/src/Symfony/Component/AccessControl/Strategy/AffirmativeStrategy.php new file mode 100644 index 000000000000..905bf82c4b03 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Strategy/AffirmativeStrategy.php @@ -0,0 +1,48 @@ + $votes + */ + public function evaluate(AccessRequest $accessRequest, iterable $votes): AccessDecision + { + $deny = 0; + + foreach ($votes as $vote) { + if ($vote->decision === DecisionVote::ACCESS_GRANTED) { + return AccessDecision::grant($accessRequest, $votes, $vote->reason); + } + + if ($vote->decision === DecisionVote::ACCESS_DENIED) { + ++$deny; + } + } + + if ($deny > 0) { + return AccessDecision::deny($accessRequest, $votes, 'At least one voter denied access.'); + } + + return AccessDecision::abstain($accessRequest, $votes, 'All voters abstained from voting.'); + } +} diff --git a/src/Symfony/Component/AccessControl/Strategy/ConsensusStrategy.php b/src/Symfony/Component/AccessControl/Strategy/ConsensusStrategy.php new file mode 100644 index 000000000000..6847c45bcfaf --- /dev/null +++ b/src/Symfony/Component/AccessControl/Strategy/ConsensusStrategy.php @@ -0,0 +1,51 @@ + $votes + */ + public function evaluate(AccessRequest $accessRequest, iterable $votes): AccessDecision + { + $grantCount = 0; + $denyCount = 0; + + foreach ($votes as $vote) { + if ($vote->decision === DecisionVote::ACCESS_GRANTED) { + $grantCount += $vote->weight; + } elseif ($vote->decision === DecisionVote::ACCESS_DENIED) { + $denyCount += $vote->weight; + } + } + + if ($denyCount > $grantCount) { + return AccessDecision::deny($accessRequest, $votes, 'A majority of voters denied access.'); + } + + if ($grantCount > $denyCount) { + return AccessDecision::grant($accessRequest, $votes, 'A majority of voters granted access.'); + } + + return AccessDecision::abstain($accessRequest, $votes, $grantCount === 0 ? 'All voters abstained from voting.' : 'There is a tie.'); + } +} diff --git a/src/Symfony/Component/AccessControl/Strategy/StrategyInterface.php b/src/Symfony/Component/AccessControl/Strategy/StrategyInterface.php new file mode 100644 index 000000000000..91dfbffe717a --- /dev/null +++ b/src/Symfony/Component/AccessControl/Strategy/StrategyInterface.php @@ -0,0 +1,20 @@ + $votes + */ + public function evaluate(AccessRequest $accessRequest, iterable $votes): AccessDecision; +} diff --git a/src/Symfony/Component/AccessControl/Strategy/UnanimousStrategy.php b/src/Symfony/Component/AccessControl/Strategy/UnanimousStrategy.php new file mode 100644 index 000000000000..f61d43ee0a22 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Strategy/UnanimousStrategy.php @@ -0,0 +1,48 @@ + $votes + */ + public function evaluate(AccessRequest $accessRequest, iterable $votes): AccessDecision + { + $grant = 0; + + foreach ($votes as $vote) { + if ($vote->decision === DecisionVote::ACCESS_DENIED) { + return AccessDecision::deny($accessRequest, $votes, $vote->reason); + } + + if ($vote->decision === DecisionVote::ACCESS_GRANTED) { + ++$grant; + } + } + + if ($grant > 0) { + return AccessDecision::grant($accessRequest, $votes, 'All non-abstaining voters granted access.'); + } + + return AccessDecision::abstain($accessRequest, $votes, 'All voters abstained from voting.'); + } +} diff --git a/src/Symfony/Component/AccessControl/Tests/AffirmativeStrategyTest.php b/src/Symfony/Component/AccessControl/Tests/AffirmativeStrategyTest.php new file mode 100644 index 000000000000..69954fc0128f --- /dev/null +++ b/src/Symfony/Component/AccessControl/Tests/AffirmativeStrategyTest.php @@ -0,0 +1,111 @@ +getTokenStorage()->setToken($token); + $accessControlManger = $this->getAccessControlManager(); + + // Act + $decision = $accessControlManger->decide($accessRequest, 'affirmative'); + + // Assert + $this->assertEquals($expectedDecision, $decision->decision); + $this->assertEquals($reason, $decision->reason); + } + + /** + * @return iterable{0: string, 1: AccessRequest, 2: DecisionVote, 3: string} + */ + public function provideScenarios(): iterable + { + yield 'affirmative strategy and deny on abstain' => [ + new NullToken(), + new AccessRequest('read', 'article'), + DecisionVote::ACCESS_DENIED, + 'All voters abstained from voting.', + ]; + + yield 'affirmative strategy and grant on abstain' => [ + new NullToken(), + new AccessRequest('read', 'article', allowIfAllAbstainOrTie: true), + DecisionVote::ACCESS_GRANTED, + 'All voters abstained from voting.', + ]; + yield 'affirmative strategy and deny on unauthenticated user' => [ + new NullToken(), + new AccessRequest('ROLE_USER'), + DecisionVote::ACCESS_DENIED, + 'At least one voter denied access.', + ]; + + $userToken = $this->createMock(TokenInterface::class); + $userToken->method('getUser')->willReturn(new FakeUser); + yield 'affirmative strategy and grant on authenticated user (classic interface)' => [ + $userToken, + new AccessRequest('ROLE_USER'), + DecisionVote::ACCESS_GRANTED, + 'The user has the required role.', + ]; + + $userToken = $this->createMock(TokenInterface::class); + $userToken->method('getUser')->willReturn(new FakeUserWithRole); + yield 'affirmative strategy and grant on authenticated user (new interface)' => [ + $userToken, + new AccessRequest('ROLE_USER'), + DecisionVote::ACCESS_GRANTED, + 'The user has the required role.', + ]; + + $userToken = $this->createMock(TokenInterface::class); + $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_SUPER_ADMIN'])); + yield 'affirmative strategy and grant on authenticated user (inherited role)' => [ + $userToken, + new AccessRequest('ROLE_ALLOWED_TO_SWITCH'), + DecisionVote::ACCESS_GRANTED, + 'The user has the required role.', + ]; + + $userToken = $this->createMock(TokenInterface::class); + $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_ADMIN'])); + yield 'affirmative strategy and deny on authenticated user (inherited role)' => [ + $userToken, + new AccessRequest('ROLE_ALLOWED_TO_SWITCH'), + DecisionVote::ACCESS_DENIED, + 'At least one voter denied access.', + ]; + + $userToken = $this->createMock(TokenInterface::class); + $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_ADMIN'])); + $expression = new Expression('"ROLE_ADMIN" in role_names and is_authenticated()'); + yield 'affirmative strategy and grant on expression' => [ + $userToken, + new AccessRequest($expression), + DecisionVote::ACCESS_GRANTED, + 'Access granted by expression', + ]; + + $userToken = $this->createMock(TokenInterface::class); + $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_ADMIN'])); + $expression = new Expression('"ROLE_SUPER_ADMIN" in role_names and is_fully_authenticated()'); + yield 'affirmative strategy and denied on expression' => [ + $userToken, + new AccessRequest($expression), + DecisionVote::ACCESS_DENIED, + 'At least one voter denied access.', + ]; + } +} diff --git a/src/Symfony/Component/AccessControl/Tests/ConsensusStrategyTest.php b/src/Symfony/Component/AccessControl/Tests/ConsensusStrategyTest.php new file mode 100644 index 000000000000..6c2dcf4bb411 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Tests/ConsensusStrategyTest.php @@ -0,0 +1,51 @@ +getTokenStorage()->setToken($token); + $accessControlManger = $this->getAccessControlManager(); + + // Act + $decision = $accessControlManger->decide($accessRequest, 'consensus'); + + // Assert + $this->assertEquals($expectedDecision, $decision->decision); + $this->assertEquals($reason, $decision->reason); + } + + /** + * @return iterable{0: string, 1: AccessRequest, 2: DecisionVote, 3: string} + */ + public function provideScenarios(): iterable + { + $userToken = $this->createMock(TokenInterface::class); + $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_SUPER_ADMIN'])); + yield 'consensus strategy and deny on authenticated user' => [ + $userToken, + new AccessRequest('ROLE_ALLOWED_TO_SWITCH'), + DecisionVote::ACCESS_DENIED, + 'There is a tie.', + ]; + + $userToken = $this->createMock(TokenInterface::class); + $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_SUPER_ADMIN'])); + yield 'consensus strategy and grant on authenticated user' => [ + $userToken, + new AccessRequest('ROLE_ALLOWED_TO_SWITCH', allowIfAllAbstainOrTie: true), + DecisionVote::ACCESS_GRANTED, + 'There is a tie.', + ]; + } +} diff --git a/src/Symfony/Component/AccessControl/Tests/EventsTest.php b/src/Symfony/Component/AccessControl/Tests/EventsTest.php new file mode 100644 index 000000000000..39216e977c4f --- /dev/null +++ b/src/Symfony/Component/AccessControl/Tests/EventsTest.php @@ -0,0 +1,27 @@ +getTokenStorage()->setToken(new NullToken()); + $accessRequest = new AccessRequest('PUBLIC_ACCESS'); + + // Act + $this->getAccessControlManager()->decide($accessRequest); + + // Assert + $events = $this->getEventDispatcher()->events; + $this->assertCount(2, $events); + $this->assertInstanceOf(VoteEvent::class, $events[0]); + $this->assertInstanceOf(AccessDecisionEvent::class, $events[1]); + } +} diff --git a/src/Symfony/Component/AccessControl/Tests/FakeEventDispatcher.php b/src/Symfony/Component/AccessControl/Tests/FakeEventDispatcher.php new file mode 100644 index 000000000000..5a6e573e4af4 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Tests/FakeEventDispatcher.php @@ -0,0 +1,16 @@ +events[] = $event; + + return $event; + } +} diff --git a/src/Symfony/Component/AccessControl/Tests/FakeTokenStorage.php b/src/Symfony/Component/AccessControl/Tests/FakeTokenStorage.php new file mode 100644 index 000000000000..ffe79d1dc919 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Tests/FakeTokenStorage.php @@ -0,0 +1,21 @@ +token; + } + + public function setToken(?TokenInterface $token): void + { + $this->token = $token; + } +} diff --git a/src/Symfony/Component/AccessControl/Tests/FakeUser.php b/src/Symfony/Component/AccessControl/Tests/FakeUser.php new file mode 100644 index 000000000000..040357909c20 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Tests/FakeUser.php @@ -0,0 +1,30 @@ +username; + } + + public function getRoles(): array + { + return $this->roles; + } +} diff --git a/src/Symfony/Component/AccessControl/Tests/FakeUserWithRole.php b/src/Symfony/Component/AccessControl/Tests/FakeUserWithRole.php new file mode 100644 index 000000000000..530497614a23 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Tests/FakeUserWithRole.php @@ -0,0 +1,30 @@ +username; + } + + public function getRoles(): array + { + return $this->roles; + } +} diff --git a/src/Symfony/Component/AccessControl/Tests/StrategyTestCase.php b/src/Symfony/Component/AccessControl/Tests/StrategyTestCase.php new file mode 100644 index 000000000000..81fb92652ed5 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Tests/StrategyTestCase.php @@ -0,0 +1,81 @@ +tokenStorage === null) { + $this->tokenStorage = new FakeTokenStorage(); + } + + return $this->tokenStorage; + } + + protected function getAccessControlManager(): AccessControlManager + { + if ($this->accessControlManager === null) { + $this->accessControlManager = new AccessControlManager( + [ + new AffirmativeStrategy(), + new ConsensusStrategy(), + new UnanimousStrategy(), + ], + [ + new ExpressionVoter( + new ExpressionLanguage(), + new AuthenticationTrustResolver(), + $this->getTokenStorage(), + $this->getRoleHierarchy(), + ), + new RoleVoter($this->getTokenStorage()), + new RoleHierarchyVoter($this->getRoleHierarchy(), $this->getTokenStorage()), + new AuthenticatedVoter( + new AuthenticationTrustResolver(), + $this->getTokenStorage() + ), + ], + dispatcher: $this->getEventDispatcher(), + ); + } + + return $this->accessControlManager; + } + + protected function getEventDispatcher(): FakeEventDispatcher + { + if ($this->eventDispatcher === null) { + $this->eventDispatcher = new FakeEventDispatcher(); + } + + return $this->eventDispatcher; + } + + protected function getRoleHierarchy(): RoleHierarchyInterface + { + return new RoleHierarchy([ + 'ROLE_ADMIN' => ['ROLE_USER'], + 'ROLE_SUPER_ADMIN' => ['ROLE_ADMIN', 'ROLE_ALLOWED_TO_SWITCH'], + ]); + } +} diff --git a/src/Symfony/Component/AccessControl/Tests/UnanimousStrategyTest.php b/src/Symfony/Component/AccessControl/Tests/UnanimousStrategyTest.php new file mode 100644 index 000000000000..596ce616a80e --- /dev/null +++ b/src/Symfony/Component/AccessControl/Tests/UnanimousStrategyTest.php @@ -0,0 +1,53 @@ +getTokenStorage()->setToken($token); + $accessControlManger = $this->getAccessControlManager(); + + // Act + $decision = $accessControlManger->decide($accessRequest, 'unanimous'); + + // Assert + $this->assertEquals($expectedDecision, $decision->decision); + $this->assertEquals($reason, $decision->reason); + } + + /** + * @return iterable{0: string, 1: AccessRequest, 2: DecisionVote, 3: string} + */ + public function provideScenarios(): iterable + { + // In this scenario, both RoleVoter and RoleHierarchyVoter will vote. The former will deny access. + $userToken = $this->createMock(TokenInterface::class); + $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_SUPER_ADMIN'])); + yield 'unanimous strategy and deny on authenticated user' => [ + $userToken, + new AccessRequest('ROLE_ALLOWED_TO_SWITCH'), + DecisionVote::ACCESS_DENIED, + 'The user does not have the required role.', + ]; + + $userToken = $this->createMock(TokenInterface::class); + $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_ADMIN'])); + yield 'unanimous strategy and grant on authenticated user' => [ + $userToken, + new AccessRequest('ROLE_ADMIN'), + DecisionVote::ACCESS_GRANTED, + 'All non-abstaining voters granted access.', + ]; + } +} diff --git a/src/Symfony/Component/AccessControl/Voter/ABAC/AuthenticatedVoter.php b/src/Symfony/Component/AccessControl/Voter/ABAC/AuthenticatedVoter.php new file mode 100644 index 000000000000..f9416cf763bc --- /dev/null +++ b/src/Symfony/Component/AccessControl/Voter/ABAC/AuthenticatedVoter.php @@ -0,0 +1,80 @@ +attribute); + $token = $this->tokenStorage->getToken(); + if (!$token instanceof TokenInterface) { + return VoterOutcome::deny('The token is not an instance of TokenInterface.'); + } + + if ($attribute === null) { + return VoterOutcome::abstain('The attribute is not an authentication state.'); + } + if ($attribute === AuthenticationState::PUBLIC_ACCESS) { + return VoterOutcome::grant('Access granted to public access'); + } + + if ($token instanceof OfflineTokenInterface) { + throw new InvalidArgumentException('Cannot decide on authentication attributes when an offline token is used.'); + } + + if (AuthenticationState::IS_AUTHENTICATED_FULLY === $attribute + && $this->authenticationTrustResolver->isFullFledged($token)) { + return VoterOutcome::grant('Access granted by fully authenticated user.'); + } + + if (AuthenticationState::IS_AUTHENTICATED_REMEMBERED === $attribute + && ($this->authenticationTrustResolver->isRememberMe($token) + || $this->authenticationTrustResolver->isFullFledged($token))) { + return VoterOutcome::grant('Access granted by remembered user.'); + } + + if (AuthenticationState::IS_AUTHENTICATED === $attribute && $this->authenticationTrustResolver->isAuthenticated($token)) { + return VoterOutcome::grant('Access granted by authenticated user.'); + } + + if (AuthenticationState::IS_REMEMBERED === $attribute && $this->authenticationTrustResolver->isRememberMe($token)) { + return VoterOutcome::grant('Access granted by remembered user.'); + } + + if (AuthenticationState::IS_IMPERSONATOR === $attribute && $token instanceof SwitchUserToken) { + return VoterOutcome::grant('Access granted by impersonator.'); + } + + return VoterOutcome::deny('The user does not have the required authentication state.'); + } + + public function supportsAttribute(mixed $attribute): bool + { + return \is_string($attribute) && \in_array($attribute, AuthenticationState::caseNames(), true); + } + + public function supportsSubject(mixed $subject): bool + { + return true; + } +} + diff --git a/src/Symfony/Component/AccessControl/Voter/ABAC/AuthenticationState.php b/src/Symfony/Component/AccessControl/Voter/ABAC/AuthenticationState.php new file mode 100644 index 000000000000..8e83ad8989eb --- /dev/null +++ b/src/Symfony/Component/AccessControl/Voter/ABAC/AuthenticationState.php @@ -0,0 +1,38 @@ + + */ + public static function caseNames(): array + { + return array_map(static fn (AuthenticationState $state):string => $state->value, self::cases()); + } + + public static function fromValue(string $state): ?AuthenticationState + { + return match ($state) { + 'IS_AUTHENTICATED_FULLY' => self::IS_AUTHENTICATED_FULLY, + 'IS_AUTHENTICATED_REMEMBERED' => self::IS_AUTHENTICATED_REMEMBERED, + 'IS_AUTHENTICATED' => self::IS_AUTHENTICATED, + 'IS_IMPERSONATOR' => self::IS_IMPERSONATOR, + 'IS_REMEMBERED' => self::IS_REMEMBERED, + 'PUBLIC_ACCESS' => self::PUBLIC_ACCESS, + default => null, + }; + } +} + diff --git a/src/Symfony/Component/AccessControl/Voter/Expression/ExpressionVoter.php b/src/Symfony/Component/AccessControl/Voter/Expression/ExpressionVoter.php new file mode 100644 index 000000000000..1659b2d0e9b2 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Voter/Expression/ExpressionVoter.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AccessControl\Voter\Expression; + +use Symfony\Component\AccessControl\AccessRequest; +use Symfony\Component\AccessControl\Voter\RBAC\UserWithRoleInterface; +use Symfony\Component\AccessControl\VoterInterface; +use Symfony\Component\AccessControl\VoterOutcome; +use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * @experimental + */ +final readonly class ExpressionVoter implements VoterInterface +{ + public function __construct( + private ExpressionLanguage $expressionLanguage, + private AuthenticationTrustResolverInterface $trustResolver, + private TokenStorageInterface $tokenStorage, + private ?RoleHierarchyInterface $roleHierarchy = null, + ) { + } + + public function supportsAttribute(mixed $attribute): bool + { + return $attribute instanceof Expression; + } + + public function supportsSubject(mixed $subject): bool + { + return true; + } + + public function vote(AccessRequest $accessRequest): VoterOutcome + { + $variables = $this->getVariables($accessRequest); + if ($this->expressionLanguage->evaluate($accessRequest->attribute, $variables)) { + return VoterOutcome::grant('Access granted by expression'); + } + + return VoterOutcome::deny('Access denied by expression'); + } + + /** + * @return array{token: TokenInterface, user: UserInterface|null, object: mixed, subject: mixed, roles: array, role_names: array, trust_resolver: AuthenticationTrustResolverInterface, auth_checker: AuthorizationCheckerInterface, request: Request|null} + */ + private function getVariables(AccessRequest $accessRequest): array + { + $token = $this->tokenStorage->getToken(); + $user = $token?->getUser(); + $roleNames = []; + if ($user !== null && ($user instanceof UserWithRoleInterface || method_exists($user, 'getRoles'))) { + $roleNames = $user->getRoles(); + } + + if ($this->roleHierarchy !== null) { + $roleNames = $this->roleHierarchy->getReachableRoleNames($roleNames); + } + + $variables = [ + 'token' => $token, + 'user' => $user, + 'object' => $accessRequest->subject, + 'subject' => $accessRequest->subject, + 'role_names' => $roleNames, + 'trust_resolver' => $this->trustResolver, + 'metadata' => $accessRequest->metadata, + ]; + + if ($accessRequest->subject instanceof Request) { + $variables['request'] = $accessRequest->subject; + } + + return $variables; + } +} diff --git a/src/Symfony/Component/AccessControl/Voter/RBAC/RoleHierarchyVoter.php b/src/Symfony/Component/AccessControl/Voter/RBAC/RoleHierarchyVoter.php new file mode 100644 index 000000000000..867ce7d92c85 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Voter/RBAC/RoleHierarchyVoter.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AccessControl\Voter\RBAC; + +use Symfony\Component\AccessControl\AccessRequest; +use Symfony\Component\AccessControl\VoterInterface; +use Symfony\Component\AccessControl\VoterOutcome; +use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * @experimental + */ +readonly class RoleHierarchyVoter extends RoleVoter +{ + public function __construct( + private RoleHierarchyInterface $roleHierarchy, + TokenStorageInterface $tokenStorage, + string $prefix = 'ROLE_', + ){ + parent::__construct($tokenStorage, $prefix); + } + + protected function extractRoles(?TokenInterface $token): array + { + $roles = parent::extractRoles($token); + + return $this->roleHierarchy->getReachableRoleNames($roles); + } +} diff --git a/src/Symfony/Component/AccessControl/Voter/RBAC/RoleVoter.php b/src/Symfony/Component/AccessControl/Voter/RBAC/RoleVoter.php new file mode 100644 index 000000000000..0f941701f5f8 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Voter/RBAC/RoleVoter.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AccessControl\Voter\RBAC; + +use Symfony\Component\AccessControl\AccessRequest; +use Symfony\Component\AccessControl\VoterInterface; +use Symfony\Component\AccessControl\VoterOutcome; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; + +/** + * @experimental + */ +readonly class RoleVoter implements VoterInterface +{ + public function __construct( + private TokenStorageInterface $tokenStorage, + private string $prefix = 'ROLE_', + ){} + + public function vote(AccessRequest $accessRequest): VoterOutcome + { + $token = $this->tokenStorage->getToken(); + $roles = $this->extractRoles($token); + + if (!\is_string($accessRequest->attribute) || !str_starts_with($accessRequest->attribute, $this->prefix)) { + return VoterOutcome::abstain('The attribute is not a role.'); + } + + if (\in_array($accessRequest->attribute, $roles, true)) { + return VoterOutcome::grant('The user has the required role.'); + } + + return VoterOutcome::deny('The user does not have the required role.'); + } + + public function supportsAttribute(mixed $attribute): bool + { + return \is_string($attribute) && str_starts_with($attribute, $this->prefix); + } + + public function supportsSubject(mixed $subject): bool + { + return true; + } + + protected function extractRoles(?TokenInterface $token): array + { + $user = $token?->getUser(); + if ($user === null) { + return []; + } + + if ($user instanceof UserWithRoleInterface || method_exists($user, 'getRoles')) { + return $user->getRoles(); + } + + return []; + } +} diff --git a/src/Symfony/Component/AccessControl/Voter/RBAC/UserWithRoleInterface.php b/src/Symfony/Component/AccessControl/Voter/RBAC/UserWithRoleInterface.php new file mode 100644 index 000000000000..8f6573c48a2a --- /dev/null +++ b/src/Symfony/Component/AccessControl/Voter/RBAC/UserWithRoleInterface.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AccessControl\Voter\RBAC; + +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * @experimental + */ +interface UserWithRoleInterface extends UserInterface +{ + /** + * Returns the roles granted to the user. + * + * @return string[] + */ + public function getRoles(): array; +} diff --git a/src/Symfony/Component/AccessControl/VoterInterface.php b/src/Symfony/Component/AccessControl/VoterInterface.php new file mode 100644 index 000000000000..416e39cbcc3d --- /dev/null +++ b/src/Symfony/Component/AccessControl/VoterInterface.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AccessControl; + +/** + * @experimental + */ +interface VoterInterface +{ + public function vote(AccessRequest $accessRequest): VoterOutcome; + + public function supportsAttribute(mixed $attribute): bool; + + public function supportsSubject(mixed $subject): bool; +} diff --git a/src/Symfony/Component/AccessControl/VoterOutcome.php b/src/Symfony/Component/AccessControl/VoterOutcome.php new file mode 100644 index 000000000000..3d25de26684e --- /dev/null +++ b/src/Symfony/Component/AccessControl/VoterOutcome.php @@ -0,0 +1,31 @@ +=8.2", + "composer/semver": "^3.0" + }, + "require-dev": { + }, + "autoload": { + "psr-4": { "Symfony\\Component\\AccessControl\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/AccessControl/phpunit.xml.dist b/src/Symfony/Component/AccessControl/phpunit.xml.dist new file mode 100644 index 000000000000..ee540691a49f --- /dev/null +++ b/src/Symfony/Component/AccessControl/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + + From 8a5587dbcd02a13175ec600c8b167b52fefe5b21 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Mon, 13 Jan 2025 15:16:32 +0100 Subject: [PATCH 2/3] Refactor role extraction to use subjects when required instead of tokens Updated role extraction logic to accept more flexible subject types, including `TokenInterface`, `UserInterface`, and `UserWithRoleInterface`. Introduced a `getUser` helper method to streamline user retrieval from supported subjects. Enhanced code clarity and compatibility with diverse subject instances. --- .../Voter/RBAC/RoleHierarchyVoter.php | 4 ++-- .../AccessControl/Voter/RBAC/RoleVoter.php | 24 +++++++++++++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/Symfony/Component/AccessControl/Voter/RBAC/RoleHierarchyVoter.php b/src/Symfony/Component/AccessControl/Voter/RBAC/RoleHierarchyVoter.php index 867ce7d92c85..77fe88dfa048 100644 --- a/src/Symfony/Component/AccessControl/Voter/RBAC/RoleHierarchyVoter.php +++ b/src/Symfony/Component/AccessControl/Voter/RBAC/RoleHierarchyVoter.php @@ -33,9 +33,9 @@ public function __construct( parent::__construct($tokenStorage, $prefix); } - protected function extractRoles(?TokenInterface $token): array + protected function extractRoles(mixed $subject): array { - $roles = parent::extractRoles($token); + $roles = parent::extractRoles($subject); return $this->roleHierarchy->getReachableRoleNames($roles); } diff --git a/src/Symfony/Component/AccessControl/Voter/RBAC/RoleVoter.php b/src/Symfony/Component/AccessControl/Voter/RBAC/RoleVoter.php index 0f941701f5f8..0aa221597c40 100644 --- a/src/Symfony/Component/AccessControl/Voter/RBAC/RoleVoter.php +++ b/src/Symfony/Component/AccessControl/Voter/RBAC/RoleVoter.php @@ -16,6 +16,7 @@ use Symfony\Component\AccessControl\VoterOutcome; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\User\UserInterface; /** * @experimental @@ -29,8 +30,7 @@ public function __construct( public function vote(AccessRequest $accessRequest): VoterOutcome { - $token = $this->tokenStorage->getToken(); - $roles = $this->extractRoles($token); + $roles = $this->extractRoles($accessRequest->subject); if (!\is_string($accessRequest->attribute) || !str_starts_with($accessRequest->attribute, $this->prefix)) { return VoterOutcome::abstain('The attribute is not a role.'); @@ -50,12 +50,14 @@ public function supportsAttribute(mixed $attribute): bool public function supportsSubject(mixed $subject): bool { - return true; + return $subject === null || $subject instanceof TokenInterface || $subject instanceof UserInterface || $subject instanceof UserWithRoleInterface; } - protected function extractRoles(?TokenInterface $token): array + protected function extractRoles(mixed $subject): array { - $user = $token?->getUser(); + assert($subject === null ||$subject instanceof TokenInterface || $subject instanceof UserInterface || $subject instanceof UserWithRoleInterface, 'The subject is not supported.'); + + $user = $this->getUser($subject); if ($user === null) { return []; } @@ -66,4 +68,16 @@ protected function extractRoles(?TokenInterface $token): array return []; } + + private function getUser(null|TokenInterface|UserInterface|UserWithRoleInterface $subject): null|UserInterface|UserWithRoleInterface + { + if ($subject === null) { + return $this->tokenStorage->getToken()?->getUser(); + } + if ($subject instanceof TokenInterface) { + return $subject->getUser(); + } + + return $subject; + } } From 7212e071f2004b72a65e527f74abf516b6e78858 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Tue, 14 Jan 2025 10:22:52 +0100 Subject: [PATCH 3/3] Refactor access control system to use token-based requester. Replaced reliance on `TokenStorageInterface` with requester tokens directly passed via `AccessRequest` objects. Introduced `MetadataBag` for improved metadata handling and marked several classes as `final`. Updated tests and strategies accordingly to simplify the architecture and enhance maintainability. --- .../AccessControl/AccessControlManager.php | 2 +- .../Component/AccessControl/AccessRequest.php | 7 +-- .../AccessControl/Attribute/AccessPolicy.php | 2 +- .../Component/AccessControl/Attribute/All.php | 2 +- .../AccessControl/Attribute/AtLeastOneOf.php | 2 +- .../Exception/InvalidStrategyException.php | 2 +- .../Listener/AccessPolicyListener.php | 8 ++- .../AccessControl/Listener/AllListener.php | 8 ++- .../Listener/AtLeastOneOfListener.php | 8 ++- .../Component/AccessControl/MetadataBag.php | 56 +++++++++++++++++++ .../Tests/AffirmativeStrategyTest.php | 30 ++++------ .../Tests/ConsensusStrategyTest.php | 9 +-- .../AccessControl/Tests/EventsTest.php | 3 +- .../AccessControl/Tests/StrategyTestCase.php | 5 +- .../Tests/UnanimousStrategyTest.php | 9 +-- .../Voter/ABAC/AuthenticatedVoter.php | 19 +++---- .../Voter/Expression/ExpressionVoter.php | 7 +-- .../Voter/RBAC/RoleHierarchyVoter.php | 13 +---- .../AccessControl/Voter/RBAC/RoleVoter.php | 26 ++------- 19 files changed, 119 insertions(+), 99 deletions(-) create mode 100644 src/Symfony/Component/AccessControl/MetadataBag.php diff --git a/src/Symfony/Component/AccessControl/AccessControlManager.php b/src/Symfony/Component/AccessControl/AccessControlManager.php index 4f48cf61143c..71d4fdca2099 100644 --- a/src/Symfony/Component/AccessControl/AccessControlManager.php +++ b/src/Symfony/Component/AccessControl/AccessControlManager.php @@ -12,7 +12,7 @@ /** * @experimental */ -class AccessControlManager implements AccessControlManagerInterface +final class AccessControlManager implements AccessControlManagerInterface { private readonly string $defaultStrategy; diff --git a/src/Symfony/Component/AccessControl/AccessRequest.php b/src/Symfony/Component/AccessControl/AccessRequest.php index 7c72673fb47f..bc24191b884f 100644 --- a/src/Symfony/Component/AccessControl/AccessRequest.php +++ b/src/Symfony/Component/AccessControl/AccessRequest.php @@ -4,19 +4,18 @@ use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\User\UserInterface; /** * @experimental */ readonly class AccessRequest { - /** - * @param array $metadata - */ public function __construct( + public null|TokenInterface $requester, public mixed $attribute, public mixed $subject = null, - public array $metadata = [], + public MetadataBag $metadata = new MetadataBag(), public bool $allowIfAllAbstainOrTie = false, ) { } diff --git a/src/Symfony/Component/AccessControl/Attribute/AccessPolicy.php b/src/Symfony/Component/AccessControl/Attribute/AccessPolicy.php index 25ebc8c219d8..f31c73f2935e 100644 --- a/src/Symfony/Component/AccessControl/Attribute/AccessPolicy.php +++ b/src/Symfony/Component/AccessControl/Attribute/AccessPolicy.php @@ -18,7 +18,7 @@ readonly class AccessPolicy { /** - * @param array $ + * @param array $metadata */ public function __construct( public mixed $attribute, diff --git a/src/Symfony/Component/AccessControl/Attribute/All.php b/src/Symfony/Component/AccessControl/Attribute/All.php index 993435bc64c8..abc2f9ea3a50 100644 --- a/src/Symfony/Component/AccessControl/Attribute/All.php +++ b/src/Symfony/Component/AccessControl/Attribute/All.php @@ -15,7 +15,7 @@ * @experimental */ #[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] -readonly class All +final readonly class All { /** * @param list $accessPolicies diff --git a/src/Symfony/Component/AccessControl/Attribute/AtLeastOneOf.php b/src/Symfony/Component/AccessControl/Attribute/AtLeastOneOf.php index a2798c764410..2de03bd28484 100644 --- a/src/Symfony/Component/AccessControl/Attribute/AtLeastOneOf.php +++ b/src/Symfony/Component/AccessControl/Attribute/AtLeastOneOf.php @@ -15,7 +15,7 @@ * @experimental */ #[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] -readonly class AtLeastOneOf +final readonly class AtLeastOneOf { /** * @param list $accessPolicies diff --git a/src/Symfony/Component/AccessControl/Exception/InvalidStrategyException.php b/src/Symfony/Component/AccessControl/Exception/InvalidStrategyException.php index ef313c1ca96f..dd7ddbf69ce5 100644 --- a/src/Symfony/Component/AccessControl/Exception/InvalidStrategyException.php +++ b/src/Symfony/Component/AccessControl/Exception/InvalidStrategyException.php @@ -5,7 +5,7 @@ /** * @experimental */ -class InvalidStrategyException extends \RuntimeException +final class InvalidStrategyException extends \RuntimeException { } diff --git a/src/Symfony/Component/AccessControl/Listener/AccessPolicyListener.php b/src/Symfony/Component/AccessControl/Listener/AccessPolicyListener.php index 385e0bffd2e1..a0df3025ab44 100644 --- a/src/Symfony/Component/AccessControl/Listener/AccessPolicyListener.php +++ b/src/Symfony/Component/AccessControl/Listener/AccessPolicyListener.php @@ -6,12 +6,14 @@ use Symfony\Component\AccessControl\AccessControlManager; use Symfony\Component\AccessControl\Attribute\AccessPolicy; use Symfony\Component\AccessControl\DecisionVote; +use Symfony\Component\AccessControl\MetadataBag; use Symfony\Component\Console\ConsoleEvents; use Symfony\Component\Console\Event\ConsoleCommandEvent; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; /** @@ -20,6 +22,7 @@ final readonly class AccessPolicyListener implements EventSubscriberInterface { public function __construct( + private TokenStorageInterface $tokenStorage, private AccessControlManager $accessControlManager, ) { } @@ -73,12 +76,13 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo private function processAttribute(AccessPolicy $attribute, array $metadata): void { $accessRequest = new AccessRequest( + $this->tokenStorage->getToken(), $attribute->attribute, $attribute->subject, - [ + new MetadataBag([ ...$attribute->metadata, ...$metadata, - ], + ]), $attribute->allowIfAllAbstain ); $accessDecision = $this->accessControlManager->decide($accessRequest, $attribute->strategy); diff --git a/src/Symfony/Component/AccessControl/Listener/AllListener.php b/src/Symfony/Component/AccessControl/Listener/AllListener.php index 64d0977f5a55..481556293791 100644 --- a/src/Symfony/Component/AccessControl/Listener/AllListener.php +++ b/src/Symfony/Component/AccessControl/Listener/AllListener.php @@ -6,12 +6,14 @@ use Symfony\Component\AccessControl\AccessControlManager; use Symfony\Component\AccessControl\Attribute\All; use Symfony\Component\AccessControl\DecisionVote; +use Symfony\Component\AccessControl\MetadataBag; use Symfony\Component\Console\ConsoleEvents; use Symfony\Component\Console\Event\ConsoleCommandEvent; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; /** @@ -20,6 +22,7 @@ final readonly class AllListener implements EventSubscriberInterface { public function __construct( + private TokenStorageInterface $tokenStorage, private AccessControlManager $accessControlManager, ) { } @@ -74,12 +77,13 @@ private function processAttribute(All $attribute, array $metadata): void { foreach ($attribute->accessPolicies as $accessPolicy) { $accessRequest = new AccessRequest( + $this->tokenStorage->getToken(), $accessPolicy->attribute, $accessPolicy->subject, - [ + new MetadataBag([ ...$accessPolicy->metadata, ...$metadata, - ], + ]), $accessPolicy->allowIfAllAbstain ); $accessDecision = $this->accessControlManager->decide($accessRequest, $accessPolicy->strategy); diff --git a/src/Symfony/Component/AccessControl/Listener/AtLeastOneOfListener.php b/src/Symfony/Component/AccessControl/Listener/AtLeastOneOfListener.php index b1329169e2ae..80071b26feb9 100644 --- a/src/Symfony/Component/AccessControl/Listener/AtLeastOneOfListener.php +++ b/src/Symfony/Component/AccessControl/Listener/AtLeastOneOfListener.php @@ -6,12 +6,14 @@ use Symfony\Component\AccessControl\AccessControlManager; use Symfony\Component\AccessControl\Attribute\All; use Symfony\Component\AccessControl\DecisionVote; +use Symfony\Component\AccessControl\MetadataBag; use Symfony\Component\Console\ConsoleEvents; use Symfony\Component\Console\Event\ConsoleCommandEvent; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; /** @@ -20,6 +22,7 @@ final readonly class AtLeastOneOfListener implements EventSubscriberInterface { public function __construct( + private TokenStorageInterface $tokenStorage, private AccessControlManager $accessControlManager, ) { } @@ -74,12 +77,13 @@ private function processAttribute(All $attribute, array $metadata): void { foreach ($attribute->accessPolicies as $accessPolicy) { $accessRequest = new AccessRequest( + $this->tokenStorage->getToken(), $accessPolicy->attribute, $accessPolicy->subject, - [ + new MetadataBag([ ...$accessPolicy->metadata, ...$metadata, - ], + ]), $accessPolicy->allowIfAllAbstain ); $accessDecision = $this->accessControlManager->decide($accessRequest, $accessPolicy->strategy); diff --git a/src/Symfony/Component/AccessControl/MetadataBag.php b/src/Symfony/Component/AccessControl/MetadataBag.php new file mode 100644 index 000000000000..530a2f6279e3 --- /dev/null +++ b/src/Symfony/Component/AccessControl/MetadataBag.php @@ -0,0 +1,56 @@ + $parameters + */ + public function __construct( + private array $parameters = [], + ) { + } + + /** + * Returns the parameter keys. + */ + public function keys(): array + { + return array_keys($this->parameters); + } + + public function get(string $key, mixed $default = null): mixed + { + return \array_key_exists($key, $this->parameters) ? $this->parameters[$key] : $default; + } + + /** + * Returns true if the parameter is defined. + */ + public function has(string $key): bool + { + return \array_key_exists($key, $this->parameters); + } + + /** + * Returns an iterator for parameters. + * + * @return \ArrayIterator + */ + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->parameters); + } + + /** + * Returns the number of parameters. + */ + public function count(): int + { + return \count($this->parameters); + } +} diff --git a/src/Symfony/Component/AccessControl/Tests/AffirmativeStrategyTest.php b/src/Symfony/Component/AccessControl/Tests/AffirmativeStrategyTest.php index 69954fc0128f..6cbf01f18122 100644 --- a/src/Symfony/Component/AccessControl/Tests/AffirmativeStrategyTest.php +++ b/src/Symfony/Component/AccessControl/Tests/AffirmativeStrategyTest.php @@ -13,10 +13,9 @@ final class AffirmativeStrategyTest extends StrategyTestCase /** * @dataProvider provideScenarios */ - public function testDecide(?TokenInterface $token, AccessRequest $accessRequest, DecisionVote $expectedDecision, ?string $reason): void + public function testDecide(AccessRequest $accessRequest, DecisionVote $expectedDecision, ?string $reason): void { // Arrange - $this->getTokenStorage()->setToken($token); $accessControlManger = $this->getAccessControlManager(); // Act @@ -33,21 +32,18 @@ public function testDecide(?TokenInterface $token, AccessRequest $accessRequest, public function provideScenarios(): iterable { yield 'affirmative strategy and deny on abstain' => [ - new NullToken(), - new AccessRequest('read', 'article'), + new AccessRequest(new NullToken(),'read', 'article'), DecisionVote::ACCESS_DENIED, 'All voters abstained from voting.', ]; yield 'affirmative strategy and grant on abstain' => [ - new NullToken(), - new AccessRequest('read', 'article', allowIfAllAbstainOrTie: true), + new AccessRequest(new NullToken(),'read', 'article', allowIfAllAbstainOrTie: true), DecisionVote::ACCESS_GRANTED, 'All voters abstained from voting.', ]; yield 'affirmative strategy and deny on unauthenticated user' => [ - new NullToken(), - new AccessRequest('ROLE_USER'), + new AccessRequest(new NullToken(),'ROLE_USER'), DecisionVote::ACCESS_DENIED, 'At least one voter denied access.', ]; @@ -55,8 +51,7 @@ public function provideScenarios(): iterable $userToken = $this->createMock(TokenInterface::class); $userToken->method('getUser')->willReturn(new FakeUser); yield 'affirmative strategy and grant on authenticated user (classic interface)' => [ - $userToken, - new AccessRequest('ROLE_USER'), + new AccessRequest($userToken,'ROLE_USER'), DecisionVote::ACCESS_GRANTED, 'The user has the required role.', ]; @@ -64,8 +59,7 @@ public function provideScenarios(): iterable $userToken = $this->createMock(TokenInterface::class); $userToken->method('getUser')->willReturn(new FakeUserWithRole); yield 'affirmative strategy and grant on authenticated user (new interface)' => [ - $userToken, - new AccessRequest('ROLE_USER'), + new AccessRequest($userToken,'ROLE_USER'), DecisionVote::ACCESS_GRANTED, 'The user has the required role.', ]; @@ -73,8 +67,7 @@ public function provideScenarios(): iterable $userToken = $this->createMock(TokenInterface::class); $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_SUPER_ADMIN'])); yield 'affirmative strategy and grant on authenticated user (inherited role)' => [ - $userToken, - new AccessRequest('ROLE_ALLOWED_TO_SWITCH'), + new AccessRequest($userToken,'ROLE_ALLOWED_TO_SWITCH'), DecisionVote::ACCESS_GRANTED, 'The user has the required role.', ]; @@ -82,8 +75,7 @@ public function provideScenarios(): iterable $userToken = $this->createMock(TokenInterface::class); $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_ADMIN'])); yield 'affirmative strategy and deny on authenticated user (inherited role)' => [ - $userToken, - new AccessRequest('ROLE_ALLOWED_TO_SWITCH'), + new AccessRequest($userToken,'ROLE_ALLOWED_TO_SWITCH'), DecisionVote::ACCESS_DENIED, 'At least one voter denied access.', ]; @@ -92,8 +84,7 @@ public function provideScenarios(): iterable $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_ADMIN'])); $expression = new Expression('"ROLE_ADMIN" in role_names and is_authenticated()'); yield 'affirmative strategy and grant on expression' => [ - $userToken, - new AccessRequest($expression), + new AccessRequest($userToken,$expression), DecisionVote::ACCESS_GRANTED, 'Access granted by expression', ]; @@ -102,8 +93,7 @@ public function provideScenarios(): iterable $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_ADMIN'])); $expression = new Expression('"ROLE_SUPER_ADMIN" in role_names and is_fully_authenticated()'); yield 'affirmative strategy and denied on expression' => [ - $userToken, - new AccessRequest($expression), + new AccessRequest($userToken,$expression), DecisionVote::ACCESS_DENIED, 'At least one voter denied access.', ]; diff --git a/src/Symfony/Component/AccessControl/Tests/ConsensusStrategyTest.php b/src/Symfony/Component/AccessControl/Tests/ConsensusStrategyTest.php index 6c2dcf4bb411..c636a515a0ab 100644 --- a/src/Symfony/Component/AccessControl/Tests/ConsensusStrategyTest.php +++ b/src/Symfony/Component/AccessControl/Tests/ConsensusStrategyTest.php @@ -11,10 +11,9 @@ final class ConsensusStrategyTest extends StrategyTestCase /** * @dataProvider provideScenarios */ - public function testDecide(?TokenInterface $token, AccessRequest $accessRequest, DecisionVote $expectedDecision, ?string $reason): void + public function testDecide(AccessRequest $accessRequest, DecisionVote $expectedDecision, ?string $reason): void { // Arrange - $this->getTokenStorage()->setToken($token); $accessControlManger = $this->getAccessControlManager(); // Act @@ -33,8 +32,7 @@ public function provideScenarios(): iterable $userToken = $this->createMock(TokenInterface::class); $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_SUPER_ADMIN'])); yield 'consensus strategy and deny on authenticated user' => [ - $userToken, - new AccessRequest('ROLE_ALLOWED_TO_SWITCH'), + new AccessRequest($userToken,'ROLE_ALLOWED_TO_SWITCH'), DecisionVote::ACCESS_DENIED, 'There is a tie.', ]; @@ -42,8 +40,7 @@ public function provideScenarios(): iterable $userToken = $this->createMock(TokenInterface::class); $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_SUPER_ADMIN'])); yield 'consensus strategy and grant on authenticated user' => [ - $userToken, - new AccessRequest('ROLE_ALLOWED_TO_SWITCH', allowIfAllAbstainOrTie: true), + new AccessRequest($userToken,'ROLE_ALLOWED_TO_SWITCH', allowIfAllAbstainOrTie: true), DecisionVote::ACCESS_GRANTED, 'There is a tie.', ]; diff --git a/src/Symfony/Component/AccessControl/Tests/EventsTest.php b/src/Symfony/Component/AccessControl/Tests/EventsTest.php index 39216e977c4f..5da9bef57dd2 100644 --- a/src/Symfony/Component/AccessControl/Tests/EventsTest.php +++ b/src/Symfony/Component/AccessControl/Tests/EventsTest.php @@ -12,8 +12,7 @@ final class EventsTest extends StrategyTestCase public function testDecide(): void { // Arrange - $this->getTokenStorage()->setToken(new NullToken()); - $accessRequest = new AccessRequest('PUBLIC_ACCESS'); + $accessRequest = new AccessRequest(new NullToken(), 'PUBLIC_ACCESS'); // Act $this->getAccessControlManager()->decide($accessRequest); diff --git a/src/Symfony/Component/AccessControl/Tests/StrategyTestCase.php b/src/Symfony/Component/AccessControl/Tests/StrategyTestCase.php index 81fb92652ed5..a0f26dc2cc3f 100644 --- a/src/Symfony/Component/AccessControl/Tests/StrategyTestCase.php +++ b/src/Symfony/Component/AccessControl/Tests/StrategyTestCase.php @@ -45,11 +45,10 @@ protected function getAccessControlManager(): AccessControlManager new ExpressionVoter( new ExpressionLanguage(), new AuthenticationTrustResolver(), - $this->getTokenStorage(), $this->getRoleHierarchy(), ), - new RoleVoter($this->getTokenStorage()), - new RoleHierarchyVoter($this->getRoleHierarchy(), $this->getTokenStorage()), + new RoleVoter(), + new RoleHierarchyVoter($this->getRoleHierarchy()), new AuthenticatedVoter( new AuthenticationTrustResolver(), $this->getTokenStorage() diff --git a/src/Symfony/Component/AccessControl/Tests/UnanimousStrategyTest.php b/src/Symfony/Component/AccessControl/Tests/UnanimousStrategyTest.php index 596ce616a80e..821f0e77cdf9 100644 --- a/src/Symfony/Component/AccessControl/Tests/UnanimousStrategyTest.php +++ b/src/Symfony/Component/AccessControl/Tests/UnanimousStrategyTest.php @@ -12,10 +12,9 @@ final class UnanimousStrategyTest extends StrategyTestCase /** * @dataProvider provideScenarios */ - public function testDecide(?TokenInterface $token, AccessRequest $accessRequest, DecisionVote $expectedDecision, ?string $reason): void + public function testDecide(AccessRequest $accessRequest, DecisionVote $expectedDecision, ?string $reason): void { // Arrange - $this->getTokenStorage()->setToken($token); $accessControlManger = $this->getAccessControlManager(); // Act @@ -35,8 +34,7 @@ public function provideScenarios(): iterable $userToken = $this->createMock(TokenInterface::class); $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_SUPER_ADMIN'])); yield 'unanimous strategy and deny on authenticated user' => [ - $userToken, - new AccessRequest('ROLE_ALLOWED_TO_SWITCH'), + new AccessRequest($userToken,'ROLE_ALLOWED_TO_SWITCH'), DecisionVote::ACCESS_DENIED, 'The user does not have the required role.', ]; @@ -44,8 +42,7 @@ public function provideScenarios(): iterable $userToken = $this->createMock(TokenInterface::class); $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_ADMIN'])); yield 'unanimous strategy and grant on authenticated user' => [ - $userToken, - new AccessRequest('ROLE_ADMIN'), + new AccessRequest($userToken,'ROLE_ADMIN'), DecisionVote::ACCESS_GRANTED, 'All non-abstaining voters granted access.', ]; diff --git a/src/Symfony/Component/AccessControl/Voter/ABAC/AuthenticatedVoter.php b/src/Symfony/Component/AccessControl/Voter/ABAC/AuthenticatedVoter.php index f9416cf763bc..75312ba9e6c1 100644 --- a/src/Symfony/Component/AccessControl/Voter/ABAC/AuthenticatedVoter.php +++ b/src/Symfony/Component/AccessControl/Voter/ABAC/AuthenticatedVoter.php @@ -7,7 +7,6 @@ use Symfony\Component\AccessControl\VoterOutcome; use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface; use Symfony\Component\Security\Core\Authentication\Token\OfflineTokenInterface; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\InvalidArgumentException; @@ -19,14 +18,12 @@ { public function __construct( private AuthenticationTrustResolverInterface $authenticationTrustResolver, - private TokenStorageInterface $tokenStorage, ){} public function vote(AccessRequest $accessRequest): VoterOutcome { $attribute = AuthenticationState::fromValue($accessRequest->attribute); - $token = $this->tokenStorage->getToken(); - if (!$token instanceof TokenInterface) { + if (!$accessRequest->requester instanceof TokenInterface) { return VoterOutcome::deny('The token is not an instance of TokenInterface.'); } @@ -37,30 +34,30 @@ public function vote(AccessRequest $accessRequest): VoterOutcome return VoterOutcome::grant('Access granted to public access'); } - if ($token instanceof OfflineTokenInterface) { + if ($accessRequest->requester instanceof OfflineTokenInterface) { throw new InvalidArgumentException('Cannot decide on authentication attributes when an offline token is used.'); } if (AuthenticationState::IS_AUTHENTICATED_FULLY === $attribute - && $this->authenticationTrustResolver->isFullFledged($token)) { + && $this->authenticationTrustResolver->isFullFledged($accessRequest->requester)) { return VoterOutcome::grant('Access granted by fully authenticated user.'); } if (AuthenticationState::IS_AUTHENTICATED_REMEMBERED === $attribute - && ($this->authenticationTrustResolver->isRememberMe($token) - || $this->authenticationTrustResolver->isFullFledged($token))) { + && ($this->authenticationTrustResolver->isRememberMe($accessRequest->requester) + || $this->authenticationTrustResolver->isFullFledged($accessRequest->requester))) { return VoterOutcome::grant('Access granted by remembered user.'); } - if (AuthenticationState::IS_AUTHENTICATED === $attribute && $this->authenticationTrustResolver->isAuthenticated($token)) { + if (AuthenticationState::IS_AUTHENTICATED === $attribute && $this->authenticationTrustResolver->isAuthenticated($accessRequest->requester)) { return VoterOutcome::grant('Access granted by authenticated user.'); } - if (AuthenticationState::IS_REMEMBERED === $attribute && $this->authenticationTrustResolver->isRememberMe($token)) { + if (AuthenticationState::IS_REMEMBERED === $attribute && $this->authenticationTrustResolver->isRememberMe($accessRequest->requester)) { return VoterOutcome::grant('Access granted by remembered user.'); } - if (AuthenticationState::IS_IMPERSONATOR === $attribute && $token instanceof SwitchUserToken) { + if (AuthenticationState::IS_IMPERSONATOR === $attribute && $accessRequest->requester instanceof SwitchUserToken) { return VoterOutcome::grant('Access granted by impersonator.'); } diff --git a/src/Symfony/Component/AccessControl/Voter/Expression/ExpressionVoter.php b/src/Symfony/Component/AccessControl/Voter/Expression/ExpressionVoter.php index 1659b2d0e9b2..e53201d37f7d 100644 --- a/src/Symfony/Component/AccessControl/Voter/Expression/ExpressionVoter.php +++ b/src/Symfony/Component/AccessControl/Voter/Expression/ExpressionVoter.php @@ -19,7 +19,6 @@ use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; @@ -33,7 +32,6 @@ public function __construct( private ExpressionLanguage $expressionLanguage, private AuthenticationTrustResolverInterface $trustResolver, - private TokenStorageInterface $tokenStorage, private ?RoleHierarchyInterface $roleHierarchy = null, ) { } @@ -63,8 +61,7 @@ public function vote(AccessRequest $accessRequest): VoterOutcome */ private function getVariables(AccessRequest $accessRequest): array { - $token = $this->tokenStorage->getToken(); - $user = $token?->getUser(); + $user = $accessRequest->requester?->getUser(); $roleNames = []; if ($user !== null && ($user instanceof UserWithRoleInterface || method_exists($user, 'getRoles'))) { $roleNames = $user->getRoles(); @@ -75,7 +72,7 @@ private function getVariables(AccessRequest $accessRequest): array } $variables = [ - 'token' => $token, + 'token' => $accessRequest->requester, 'user' => $user, 'object' => $accessRequest->subject, 'subject' => $accessRequest->subject, diff --git a/src/Symfony/Component/AccessControl/Voter/RBAC/RoleHierarchyVoter.php b/src/Symfony/Component/AccessControl/Voter/RBAC/RoleHierarchyVoter.php index 77fe88dfa048..6be992c31aa1 100644 --- a/src/Symfony/Component/AccessControl/Voter/RBAC/RoleHierarchyVoter.php +++ b/src/Symfony/Component/AccessControl/Voter/RBAC/RoleHierarchyVoter.php @@ -11,14 +11,8 @@ namespace Symfony\Component\AccessControl\Voter\RBAC; -use Symfony\Component\AccessControl\AccessRequest; -use Symfony\Component\AccessControl\VoterInterface; -use Symfony\Component\AccessControl\VoterOutcome; -use Symfony\Component\ExpressionLanguage\Expression; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; -use Symfony\Component\Security\Core\User\UserInterface; /** * @experimental @@ -27,15 +21,14 @@ { public function __construct( private RoleHierarchyInterface $roleHierarchy, - TokenStorageInterface $tokenStorage, string $prefix = 'ROLE_', ){ - parent::__construct($tokenStorage, $prefix); + parent::__construct($prefix); } - protected function extractRoles(mixed $subject): array + protected function extractRoles(?TokenInterface $requester): array { - $roles = parent::extractRoles($subject); + $roles = parent::extractRoles($requester); return $this->roleHierarchy->getReachableRoleNames($roles); } diff --git a/src/Symfony/Component/AccessControl/Voter/RBAC/RoleVoter.php b/src/Symfony/Component/AccessControl/Voter/RBAC/RoleVoter.php index 0aa221597c40..078e6e982c05 100644 --- a/src/Symfony/Component/AccessControl/Voter/RBAC/RoleVoter.php +++ b/src/Symfony/Component/AccessControl/Voter/RBAC/RoleVoter.php @@ -14,7 +14,6 @@ use Symfony\Component\AccessControl\AccessRequest; use Symfony\Component\AccessControl\VoterInterface; use Symfony\Component\AccessControl\VoterOutcome; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\User\UserInterface; @@ -24,13 +23,12 @@ readonly class RoleVoter implements VoterInterface { public function __construct( - private TokenStorageInterface $tokenStorage, private string $prefix = 'ROLE_', ){} public function vote(AccessRequest $accessRequest): VoterOutcome { - $roles = $this->extractRoles($accessRequest->subject); + $roles = $this->extractRoles($accessRequest->requester); if (!\is_string($accessRequest->attribute) || !str_starts_with($accessRequest->attribute, $this->prefix)) { return VoterOutcome::abstain('The attribute is not a role.'); @@ -50,15 +48,13 @@ public function supportsAttribute(mixed $attribute): bool public function supportsSubject(mixed $subject): bool { - return $subject === null || $subject instanceof TokenInterface || $subject instanceof UserInterface || $subject instanceof UserWithRoleInterface; + return true; } - protected function extractRoles(mixed $subject): array + protected function extractRoles(?TokenInterface $requester): array { - assert($subject === null ||$subject instanceof TokenInterface || $subject instanceof UserInterface || $subject instanceof UserWithRoleInterface, 'The subject is not supported.'); - - $user = $this->getUser($subject); - if ($user === null) { + $user = $requester?->getUser(); + if (!$user instanceof UserInterface && !$user instanceof UserWithRoleInterface) { return []; } @@ -68,16 +64,4 @@ protected function extractRoles(mixed $subject): array return []; } - - private function getUser(null|TokenInterface|UserInterface|UserWithRoleInterface $subject): null|UserInterface|UserWithRoleInterface - { - if ($subject === null) { - return $this->tokenStorage->getToken()?->getUser(); - } - if ($subject instanceof TokenInterface) { - return $subject->getUser(); - } - - return $subject; - } }