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 b6099e8954947..2e6528a13c36f 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 0000000000000..14c3c35940427 --- /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 0000000000000..c7f1fdae683c2 --- /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 0000000000000..4f48cf61143c9 --- /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 0000000000000..35080429769f1 --- /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 0000000000000..7c72673fb47f5 --- /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 0000000000000..25ebc8c219d85 --- /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 0000000000000..993435bc64c8d --- /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 0000000000000..a2798c7644104 --- /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 0000000000000..0f29770616c5f --- /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 0000000000000..df9640d136618 --- /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 0000000000000..e5c0e8c7d479a --- /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 0000000000000..3ed9f412ce53d --- /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 0000000000000..385e0bffd2e1a --- /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 0000000000000..64d0977f5a554 --- /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 0000000000000..b1329169e2ae4 --- /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 0000000000000..21118b83c5565 --- /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 0000000000000..905bf82c4b03d --- /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 0000000000000..6847c45bcfaff --- /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 0000000000000..91dfbffe717a5 --- /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 0000000000000..f61d43ee0a220 --- /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 0000000000000..69954fc0128ff --- /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 0000000000000..6c2dcf4bb411f --- /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 0000000000000..39216e977c4f2 --- /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 0000000000000..5a6e573e4af46 --- /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 0000000000000..ffe79d1dc9195 --- /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 0000000000000..040357909c20c --- /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 0000000000000..530497614a234 --- /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 0000000000000..81fb92652ed5b --- /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 0000000000000..596ce616a80e4 --- /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 0000000000000..f9416cf763bce --- /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 0000000000000..8e83ad8989eb8 --- /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 0000000000000..1659b2d0e9b2b --- /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 0000000000000..867ce7d92c85c --- /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 0000000000000..0f941701f5f82 --- /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 0000000000000..8f6573c48a2aa --- /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 0000000000000..416e39cbcc3da --- /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 0000000000000..3d25de26684e4 --- /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 0000000000000..ee540691a49f4 --- /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 867ce7d92c85c..77fe88dfa048e 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 0f941701f5f82..0aa221597c40a 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 4f48cf61143c9..71d4fdca20994 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 7c72673fb47f5..bc24191b884f6 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 25ebc8c219d85..f31c73f2935eb 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 993435bc64c8d..abc2f9ea3a50d 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 a2798c7644104..2de03bd28484d 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 ef313c1ca96f5..dd7ddbf69ce50 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 385e0bffd2e1a..a0df3025ab444 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 64d0977f5a554..4815562937912 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 b1329169e2ae4..80071b26feb99 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 0000000000000..530a2f6279e32 --- /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 69954fc0128ff..6cbf01f18122b 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 6c2dcf4bb411f..c636a515a0abf 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 39216e977c4f2..5da9bef57dd2c 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 81fb92652ed5b..a0f26dc2cc3f6 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 596ce616a80e4..821f0e77cdf90 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 f9416cf763bce..75312ba9e6c1f 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 1659b2d0e9b2b..e53201d37f7d8 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 77fe88dfa048e..6be992c31aa17 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 0aa221597c40a..078e6e982c05e 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; - } } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy