From fbad275e6a8c32b102495381a8e2360ec5acdb18 Mon Sep 17 00:00:00 2001 From: eltharin Date: Tue, 27 Aug 2024 19:49:01 +0200 Subject: [PATCH 01/12] vote scoring and messages --- .../Controller/AbstractController.php | 30 +- .../DependencyInjection/MainConfiguration.php | 3 + .../DependencyInjection/SecurityExtension.php | 2 + .../views/Collector/security.html.twig | 456 +++++++----------- .../Bundle/SecurityBundle/Security.php | 18 +- .../Tests/EventListener/VoteListenerTest.php | 23 +- .../Core/Authorization/AccessDecision.php | 98 ++++ .../Authorization/AccessDecisionManager.php | 50 +- .../AccessDecisionManagerInterface.php | 2 + .../Authorization/AuthorizationChecker.php | 12 +- .../AuthorizationCheckerInterface.php | 2 + .../AccessDecisionStrategyInterface.php | 4 + .../Strategy/AffirmativeStrategy.php | 24 +- .../Strategy/ConsensusStrategy.php | 25 +- .../Strategy/PriorityStrategy.php | 23 +- .../Strategy/ScoringStrategy.php | 64 +++ .../Strategy/UnanimousStrategy.php | 24 +- .../TraceableAccessDecisionManager.php | 31 +- .../Voter/AuthenticatedVoter.php | 21 +- .../Authorization/Voter/ExpressionVoter.php | 11 +- .../Core/Authorization/Voter/RoleVoter.php | 11 +- .../Authorization/Voter/TraceableVoter.php | 15 +- .../Core/Authorization/Voter/Vote.php | 116 +++++ .../Authorization/Voter/VoteInterface.php | 38 ++ .../Core/Authorization/Voter/Voter.php | 30 +- .../Authorization/Voter/VoterInterface.php | 4 +- .../Component/Security/Core/CHANGELOG.md | 1 + .../Security/Core/Event/VoteEvent.php | 9 +- .../Core/Exception/AccessDeniedException.php | 28 ++ .../Test/AccessDecisionStrategyTestCase.php | 48 +- .../AccessDecisionManagerTest.php | 197 +++++--- .../AuthorizationCheckerTest.php | 92 +++- .../Strategy/AffirmativeStrategyTest.php | 24 +- .../Strategy/ConsensusStrategyTest.php | 87 +++- .../Strategy/PriorityStrategyTest.php | 26 +- .../Strategy/ScoringStrategyTest.php | 153 ++++++ .../Strategy/UnanimousStrategyTest.php | 35 +- .../TraceableAccessDecisionManagerTest.php | 36 +- ...ccessDecisionManagerWithVoteObjectTest.php | 295 +++++++++++ .../Voter/AuthenticatedVoterTest.php | 10 + .../Voter/ExpressionVoterTest.php | 10 + .../Voter/RoleHierarchyVoterTest.php | 20 + .../Authorization/Voter/RoleVoterTest.php | 10 + .../Voter/TraceableVoterTest.php | 24 + .../Tests/Authorization/Voter/VoterTest.php | 15 + .../Exception/AccessDeniedExceptionTest.php | 73 +++ .../IsGrantedAttributeListener.php | 11 +- .../Security/Http/Firewall/AccessListener.php | 18 +- .../Http/Firewall/SwitchUserListener.php | 15 +- .../Tests/Firewall/AccessListenerTest.php | 81 +++- .../Tests/Firewall/SwitchUserListenerTest.php | 122 +++-- 51 files changed, 2042 insertions(+), 535 deletions(-) create mode 100644 src/Symfony/Component/Security/Core/Authorization/AccessDecision.php create mode 100644 src/Symfony/Component/Security/Core/Authorization/Strategy/ScoringStrategy.php create mode 100644 src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php create mode 100644 src/Symfony/Component/Security/Core/Authorization/Voter/VoteInterface.php create mode 100644 src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ScoringStrategyTest.php create mode 100644 src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerWithVoteObjectTest.php create mode 100644 src/Symfony/Component/Security/Core/Tests/Exception/AccessDeniedExceptionTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php b/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php index af453619b5ab8..c6e28b3760d49 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php +++ b/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php @@ -35,7 +35,9 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Csrf\CsrfToken; @@ -202,6 +204,20 @@ protected function isGranted(mixed $attribute, mixed $subject = null): bool return $this->container->get('security.authorization_checker')->isGranted($attribute, $subject); } + /** + * Checks decision of the attribute against the current authentication token and optionally supplied subject. + * + * @throws \LogicException + */ + protected function getDecision(mixed $attribute, mixed $subject = null): AccessDecision + { + if (!$this->container->has('security.authorization_checker')) { + throw new \LogicException('The SecurityBundle is not registered in your application. Try running "composer require symfony/security-bundle".'); + } + + return $this->container->get('security.authorization_checker')->getDecision($attribute, $subject); + } + /** * Throws an exception unless the attribute is granted against the current authentication token and optionally * supplied subject. @@ -210,10 +226,22 @@ protected function isGranted(mixed $attribute, mixed $subject = null): bool */ protected function denyAccessUnlessGranted(mixed $attribute, mixed $subject = null, string $message = 'Access Denied.'): void { - if (!$this->isGranted($attribute, $subject)) { + if (!$this->container->has('security.authorization_checker')) { + throw new \LogicException('The SecurityBundle is not registered in your application. Try running "composer require symfony/security-bundle".'); + } + + $checker = $this->container->get('security.authorization_checker'); + if (method_exists($checker, 'getDecision')) { + $decision = $checker->getDecision($attribute, $subject); + } else { + $decision = new AccessDecision($checker->isGranted($attribute, $subject) ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED); + } + + if (!$decision->isGranted()) { $exception = $this->createAccessDeniedException($message); $exception->setAttributes([$attribute]); $exception->setSubject($subject); + $exception->setAccessDecision($decision); throw $exception; } diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php index 9854a1f047a7a..55398fd98e9bd 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php @@ -36,6 +36,8 @@ class MainConfiguration implements ConfigurationInterface public const STRATEGY_UNANIMOUS = 'unanimous'; /** @internal */ public const STRATEGY_PRIORITY = 'priority'; + /** @internal */ + public const STRATEGY_SCORING = 'scoring'; /** * @param array $factories @@ -473,6 +475,7 @@ private function getAccessDecisionStrategies(): array self::STRATEGY_CONSENSUS, self::STRATEGY_UNANIMOUS, self::STRATEGY_PRIORITY, + self::STRATEGY_SCORING, ]; } } diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index dd1b8cdb490cc..8425d2a0471fe 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -52,6 +52,7 @@ use Symfony\Component\Security\Core\Authorization\Strategy\AffirmativeStrategy; use Symfony\Component\Security\Core\Authorization\Strategy\ConsensusStrategy; use Symfony\Component\Security\Core\Authorization\Strategy\PriorityStrategy; +use Symfony\Component\Security\Core\Authorization\Strategy\ScoringStrategy; use Symfony\Component\Security\Core\Authorization\Strategy\UnanimousStrategy; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\User\ChainUserChecker; @@ -194,6 +195,7 @@ private function createStrategyDefinition(string $strategy, bool $allowIfAllAbst MainConfiguration::STRATEGY_CONSENSUS => new Definition(ConsensusStrategy::class, [$allowIfAllAbstainDecisions, $allowIfEqualGrantedDeniedDecisions]), MainConfiguration::STRATEGY_UNANIMOUS => new Definition(UnanimousStrategy::class, [$allowIfAllAbstainDecisions]), MainConfiguration::STRATEGY_PRIORITY => new Definition(PriorityStrategy::class, [$allowIfAllAbstainDecisions]), + MainConfiguration::STRATEGY_SCORING => new Definition(ScoringStrategy::class, [$allowIfAllAbstainDecisions]), default => throw new InvalidConfigurationException(\sprintf('The strategy "%s" is not supported.', $strategy)), }; } diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig b/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig index 635d61e2dd2c8..ddd4a80f0fa11 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig +++ b/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig @@ -29,50 +29,10 @@ padding: 0 0 8px 0; } - #collector-content .authenticator-name { - align-items: center; - display: flex; - gap: 16px; - } - - #collector-content .authenticators .toggle-button { - margin-left: auto; - } - #collector-content .authenticators .sf-toggle-on .toggle-button { - transform: rotate(180deg); - } - #collector-content .authenticators .toggle-button svg { - display: block; - } - - #collector-content .authenticators th, - #collector-content .authenticators td { - vertical-align: baseline; - } - #collector-content .authenticators th, - #collector-content .authenticators td { - vertical-align: baseline; - } - - #collector-content .authenticators .label { - display: block; - text-align: center; - } - - #collector-content .authenticator-data { - box-shadow: none; - margin: 0; - } - - #collector-content .authenticator-data tr:first-child th, - #collector-content .authenticator-data tr:first-child td { - border-top: 0; - } - #collector-content .authenticators .badge { color: var(--white); display: inline-block; - margin: 4px 0; + text-align: center; } #collector-content .authenticators .badge.badge-resolved { background-color: var(--green-500); @@ -80,6 +40,13 @@ #collector-content .authenticators .badge.badge-not_resolved { background-color: var(--yellow-500); } + + #collector-content .authenticators svg[data-icon-name="icon-tabler-check"] { + color: var(--green-500); + } + #collector-content .authenticators svg[data-icon-name="icon-tabler-x"] { + color: var(--red-500); + } {% endblock %} @@ -214,64 +181,45 @@ {{ source('@WebProfiler/Icon/' ~ (collector.authenticated ? 'yes' : 'no') ~ '.svg') }} Authenticated - - {% if collector.authProfileToken %} - - {% endif %} - - - - + + + + - - - + + - + {% if not collector.authenticated and collector.roles is empty %} +

User is not authenticated probably because they have no roles.

+ {% endif %} + + - {% if collector.supportsRoleHierarchy %} + {% if collector.supportsRoleHierarchy %} - {% endif %} + {% endif %} - {% if collector.token %} + {% if collector.token %} - {% endif %} + {% endif %}
PropertyValue
PropertyValue
Roles - {{ collector.roles is empty ? 'none' : profiler_dump(collector.roles, maxDepth=1) }} +
Roles + {{ collector.roles is empty ? 'none' : profiler_dump(collector.roles, maxDepth=1) }} - {% if not collector.authenticated and collector.roles is empty %} -

User is not authenticated probably because they have no roles.

- {% endif %} -
Inherited Roles {{ collector.inheritedRoles is empty ? 'none' : profiler_dump(collector.inheritedRoles, maxDepth=1) }}
Token {{ profiler_dump(collector.token) }}
{% elseif collector.enabled %}
-

- There is no security token. - {% if collector.deauthProfileToken %} - It was removed in - - {{- collector.deauthProfileToken -}} - . - {% endif %} -

+

There is no security token.

{% endif %} @@ -300,40 +248,40 @@

Configuration

- - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyValue
KeyValue
provider{{ collector.firewall.provider ?: '(none)' }}
context{{ collector.firewall.context ?: '(none)' }}
entry_point{{ collector.firewall.entry_point ?: '(none)' }}
user_checker{{ collector.firewall.user_checker ?: '(none)' }}
access_denied_handler{{ collector.firewall.access_denied_handler ?: '(none)' }}
access_denied_url{{ collector.firewall.access_denied_url ?: '(none)' }}
authenticators{{ collector.firewall.authenticators is empty ? '(none)' : profiler_dump(collector.firewall.authenticators, maxDepth=1) }}
provider{{ collector.firewall.provider ?: '(none)' }}
context{{ collector.firewall.context ?: '(none)' }}
entry_point{{ collector.firewall.entry_point ?: '(none)' }}
user_checker{{ collector.firewall.user_checker ?: '(none)' }}
access_denied_handler{{ collector.firewall.access_denied_handler ?: '(none)' }}
access_denied_url{{ collector.firewall.access_denied_url ?: '(none)' }}
authenticators{{ collector.firewall.authenticators is empty ? '(none)' : profiler_dump(collector.firewall.authenticators, maxDepth=1) }}
{% endif %} @@ -370,7 +318,7 @@ {{ profiler_dump(listener.stub) }} - {{ listener.time is null ? '(none)' : '%0.2f ms'|format(listener.time * 1000) }} + {{ '%0.2f'|format(listener.time * 1000) }} ms {{ listener.response ? profiler_dump(listener.response) : '(none)' }} @@ -388,90 +336,48 @@
{% if collector.authenticators|default([]) is not empty %} - - - - - - + + + + + + - {% for i, authenticator in collector.authenticators %} - - + {% endif %} + + + {% set previous_event = authenticator %} + {% endif %} + + + + + + + + - + + {% if loop.last %} + + {% endif %} {% endfor %}
StatusAuthenticatorAuthenticatorSupportsAuthenticatedDurationPassportBadges
- {% if authenticator.authenticated %} - {% set status_text, label_status = 'success', 'success' %} - {% elseif authenticator.authenticated is null %} - {% set status_text, label_status = 'skipped', false %} + + {% set previous_event = (collector.listeners|first) %} + {% for authenticator in collector.authenticators %} + {% if loop.first or authenticator != previous_event %} + {% if not loop.first %} +
{{ profiler_dump(authenticator.stub) }}{{ source('@WebProfiler/Icon/' ~ (authenticator.supports ? 'yes' : 'no') ~ '.svg') }}{{ authenticator.authenticated is not null ? source('@WebProfiler/Icon/' ~ (authenticator.authenticated ? 'yes' : 'no') ~ '.svg') : '' }}{{ '%0.2f'|format(authenticator.duration * 1000) }} ms{{ authenticator.passport ? profiler_dump(authenticator.passport) : '(none)' }} + {% for badge in authenticator.badges ?? [] %} + + {{ badge.stub|abbr_class }} + {% else %} - {% set status_text, label_status = 'failure', 'error' %} - {% endif %} - {{ status_text }} - - - {{ profiler_dump(authenticator.stub) }} - - -
- {% if authenticator.supports is same as(false) %} -
-

This authenticator did not support the request.

-
- {% elseif authenticator.authenticated is null %} -
-

An authenticator ran before this one.

-
- {% else %} - - - - - - - - - - - - - - {% if authenticator.passport %} - - - - - {% endif %} - {% if authenticator.badges %} - - - - - {% endif %} - {% if authenticator.exception %} - - - - - {% endif %} -
Lazy{{ authenticator.supports is null ? 'yes' : 'no' }}
Duration{{ '%0.2f ms'|format(authenticator.duration * 1000) }}
Passport{{ profiler_dump(authenticator.passport) }}
Badges - {% for badge in authenticator.badges %} - - {{ badge.stub|abbr_class }} - - {% endfor %} -
Exception{{ profiler_dump(authenticator.exception) }}
- {% endif %} -
+ (none) + {% endfor %}
{% else %} @@ -495,104 +401,98 @@ - - - - + + + + - {% for voter in collector.voters %} - - - - - {% endfor %} + {% for voter in collector.voters %} + + + + + {% endfor %}
#Voter class
#Voter class
{{ loop.index }}{{ profiler_dump(voter) }}
{{ loop.index }}{{ profiler_dump(voter) }}
{% endif %} {% if collector.accessDecisionLog|default([]) is not empty %} -

Access decision log

- - - - - - - - - - - - - - - - - - {% for decision in collector.accessDecisionLog %} - - - - - - - - - + + + + + + + {% endfor %} + +
#ResultAttributesObject
{{ loop.index }} - {{ decision.result - ? 'GRANTED' - : 'DENIED' - }} - - {% if decision.attributes|length == 1 %} - {% set attribute = decision.attributes|first %} - {% if attribute.expression is defined %} - Expression:
{{ attribute.expression }}
- {% elseif attribute.type == 'string' %} - {{ attribute }} - {% else %} - {{ profiler_dump(attribute) }} - {% endif %} - {% else %} - {{ profiler_dump(decision.attributes) }} - {% endif %} -
{{ profiler_dump(decision.seek('object')) }}
- {% if decision.voter_details is not empty %} - {% set voter_details_id = 'voter-details-' ~ loop.index %} -
- - - {% for voter_detail in decision.voter_details %} - - - {% if collector.voterStrategy == 'unanimous' %} - - {% endif %} - - - {% endfor %} - -
{{ profiler_dump(voter_detail['class']) }}attribute {{ voter_detail['attributes'][0] }} - {% if voter_detail['vote'] == constant('Symfony\\Component\\Security\\Core\\Authorization\\Voter\\VoterInterface::ACCESS_GRANTED') %} - ACCESS GRANTED - {% elseif voter_detail['vote'] == constant('Symfony\\Component\\Security\\Core\\Authorization\\Voter\\VoterInterface::ACCESS_ABSTAIN') %} - ACCESS ABSTAIN - {% elseif voter_detail['vote'] == constant('Symfony\\Component\\Security\\Core\\Authorization\\Voter\\VoterInterface::ACCESS_DENIED') %} - ACCESS DENIED - {% else %} - unknown ({{ voter_detail['vote'] }}) - {% endif %} -
-
- Show voter details +

Access decision log

+ + + + + + + + + + + + + + + + + {% for decision in collector.accessDecisionLog %} + + + + - - {% endfor %} - -
#ResultAttributesObject
{{ loop.index }} + + {{ decision.result.access == 1 + ? 'GRANTED' + : 'DENIED' + }} + {{ decision.result.message }} + + {% if decision.attributes|length == 1 %} + {% set attribute = decision.attributes|first %} + {% if attribute.expression is defined %} + Expression:
{{ attribute.expression }}
+ {% elseif attribute.type == 'string' %} + {{ attribute }} + {% else %} + {{ profiler_dump(attribute) }} {% endif %} -
- + {% else %} + {{ profiler_dump(decision.attributes) }} + {% endif %} +
{{ profiler_dump(decision.seek('object')) }}
+ {% if decision.voter_details is not empty %} + {% set voter_details_id = 'voter-details-' ~ loop.index %} +
+ + + {% for voter_detail in decision.voter_details %} + + + {% if collector.voterStrategy == 'unanimous' %} + + {% endif %} + + + + {% endfor %} + +
{{ profiler_dump(voter_detail['class']) }}attribute {{ voter_detail['attributes'][0] }} + {{ voter_detail['vote'].voteResultMessage }} + {{ voter_detail['vote'].message|default('~') }}
+
+ Show voter details + {% endif %} +
+
{% endif %} diff --git a/src/Symfony/Bundle/SecurityBundle/Security.php b/src/Symfony/Bundle/SecurityBundle/Security.php index 0cb23c7601b0b..878c59d187f38 100644 --- a/src/Symfony/Bundle/SecurityBundle/Security.php +++ b/src/Symfony/Bundle/SecurityBundle/Security.php @@ -17,8 +17,10 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Exception\LogicException; use Symfony\Component\Security\Core\Exception\LogoutException; use Symfony\Component\Security\Core\User\UserInterface; @@ -60,8 +62,20 @@ public function getUser(): ?UserInterface */ public function isGranted(mixed $attributes, mixed $subject = null): bool { - return $this->container->get('security.authorization_checker') - ->isGranted($attributes, $subject); + return $this->getDecision($attributes, $subject)->isGranted(); + } + + /** + * Get the access decision against the current authentication token and optionally supplied subject. + */ + public function getDecision(mixed $attribute, mixed $subject = null): AccessDecision + { + $checker = $this->container->get('security.authorization_checker'); + if (method_exists($checker, 'getDecision')) { + return $checker->getDecision($attribute, $subject); + } + + return new AccessDecision($checker->isGranted($attribute, $subject) ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED); } public function getToken(): ?TokenInterface diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/EventListener/VoteListenerTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/EventListener/VoteListenerTest.php index d262effae29ac..a90a4cd601532 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/EventListener/VoteListenerTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/EventListener/VoteListenerTest.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Bundle\SecurityBundle\EventListener\VoteListener; use Symfony\Component\Security\Core\Authorization\TraceableAccessDecisionManager; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Event\VoteEvent; @@ -32,9 +33,29 @@ public function testOnVoterVote() $traceableAccessDecisionManager ->expects($this->once()) ->method('addVoterVote') - ->with($voter, ['myattr1', 'myattr2'], VoterInterface::ACCESS_GRANTED); + ->with($voter, ['myattr1', 'myattr2'], new Vote(VoterInterface::ACCESS_GRANTED)); $sut = new VoteListener($traceableAccessDecisionManager); $sut->onVoterVote(new VoteEvent($voter, 'mysubject', ['myattr1', 'myattr2'], VoterInterface::ACCESS_GRANTED)); } + + public function testOnVoterVoteWithObject() + { + $voter = $this->createMock(VoterInterface::class); + + $traceableAccessDecisionManager = $this + ->getMockBuilder(TraceableAccessDecisionManager::class) + ->disableOriginalConstructor() + ->onlyMethods(['addVoterVote']) + ->getMock(); + + $vote = new Vote(VoterInterface::ACCESS_GRANTED); + $traceableAccessDecisionManager + ->expects($this->once()) + ->method('addVoterVote') + ->with($voter, ['myattr1', 'myattr2'], $vote); + + $sut = new VoteListener($traceableAccessDecisionManager); + $sut->onVoterVote(new VoteEvent($voter, 'mysubject', ['myattr1', 'myattr2'], $vote)); + } } diff --git a/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php b/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php new file mode 100644 index 0000000000000..c0065a2988b2d --- /dev/null +++ b/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Authorization; + +use Symfony\Component\Security\Core\Authorization\Voter\Vote; +use Symfony\Component\Security\Core\Authorization\Voter\Voter; +use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; + +/** + * An AccessDecision is returned by an AccessDecisionManager and contains the access verdict and all the related votes. + * + * @author Dany Maillard + * @author Roman JOLY + */ +final class AccessDecision +{ + /** + * @param int $access One of the VoterInterface::ACCESS_* constants + * @param Vote[] $votes + */ + public function __construct(private readonly int $access, private readonly array $votes = [], private readonly string $message = '') + { + } + + public function getAccess(): int + { + return $this->access; + } + + public function isGranted(): bool + { + return VoterInterface::ACCESS_GRANTED === $this->access; + } + + public function isAbstain(): bool + { + return VoterInterface::ACCESS_ABSTAIN === $this->access; + } + + public function isDenied(): bool + { + return VoterInterface::ACCESS_DENIED === $this->access; + } + + public function getMessage(): string + { + return $this->message; + } + + /** + * @return Vote[] + */ + public function getVotes(): array + { + return $this->votes; + } + + /** + * @return Vote[] + */ + public function getGrantedVotes(): array + { + return $this->getVotesByAccess(Voter::ACCESS_GRANTED); + } + + /** + * @return Vote[] + */ + public function getAbstainedVotes(): array + { + return $this->getVotesByAccess(Voter::ACCESS_ABSTAIN); + } + + /** + * @return Vote[] + */ + public function getDeniedVotes(): array + { + return $this->getVotesByAccess(Voter::ACCESS_DENIED); + } + + /** + * @return Vote[] + */ + private function getVotesByAccess(int $access): array + { + return array_filter($this->votes, static fn (Vote $vote): bool => $vote->getAccess() === $access); + } +} diff --git a/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php b/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php index 3e42c4bf0af98..7ec4bcb8fb461 100644 --- a/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php +++ b/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php @@ -15,6 +15,7 @@ use Symfony\Component\Security\Core\Authorization\Strategy\AccessDecisionStrategyInterface; use Symfony\Component\Security\Core\Authorization\Strategy\AffirmativeStrategy; use Symfony\Component\Security\Core\Authorization\Voter\CacheableVoterInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Exception\InvalidArgumentException; @@ -26,12 +27,6 @@ */ final class AccessDecisionManager implements AccessDecisionManagerInterface { - private const VALID_VOTES = [ - VoterInterface::ACCESS_GRANTED => true, - VoterInterface::ACCESS_DENIED => true, - VoterInterface::ACCESS_ABSTAIN => true, - ]; - private array $votersCacheAttributes = []; private array $votersCacheObject = []; private AccessDecisionStrategyInterface $strategy; @@ -46,6 +41,27 @@ public function __construct( $this->strategy = $strategy ?? new AffirmativeStrategy(); } + public function getDecision(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false): AccessDecision + { + // Special case for AccessListener, do not remove the right side of the condition before 6.0 + if (\count($attributes) > 1 && !$allowMultipleAttributes) { + throw new InvalidArgumentException(\sprintf('Passing more than one Security attribute to "%s()" is not supported.', __METHOD__)); + } + + if (method_exists($this->strategy, 'getDecision')) { + $decision = $this->strategy->getDecision( + $this->collectVotes($token, $attributes, $object) + ); + } else { + $decision = new AccessDecision( + $this->strategy->decide($this->collectResults($token, $attributes, $object)) + ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED + ); + } + + return $decision; + } + /** * @param bool $allowMultipleAttributes Whether to allow passing multiple values to the $attributes array */ @@ -62,17 +78,27 @@ public function decide(TokenInterface $token, array $attributes, mixed $object = } /** - * @return \Traversable + * @return \Traversable */ - private function collectResults(TokenInterface $token, array $attributes, mixed $object): \Traversable + private function collectVotes(TokenInterface $token, array $attributes, mixed $object): \Traversable { foreach ($this->getVoters($attributes, $object) as $voter) { - $result = $voter->vote($token, $object, $attributes); - if (!\is_int($result) || !(self::VALID_VOTES[$result] ?? false)) { - throw new \LogicException(\sprintf('"%s::vote()" must return one of "%s" constants ("ACCESS_GRANTED", "ACCESS_DENIED" or "ACCESS_ABSTAIN"), "%s" returned.', get_debug_type($voter), VoterInterface::class, var_export($result, true))); + if (method_exists($voter, 'getVote')) { + yield $voter->getVote($token, $object, $attributes); + } else { + yield new Vote($voter->vote($token, $object, $attributes)); } + } + } - yield $result; + /** + * @return \Traversable + */ + private function collectResults(TokenInterface $token, array $attributes, mixed $object): \Traversable + { + /** @var Vote $vote */ + foreach ($this->collectVotes($token, $attributes, $object) as $vote) { + yield $vote->getAccess(); } } diff --git a/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManagerInterface.php b/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManagerInterface.php index f25c7e1bef9b3..6dcdf8c3dfd20 100644 --- a/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManagerInterface.php +++ b/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManagerInterface.php @@ -17,6 +17,8 @@ * AccessDecisionManagerInterface makes authorization decisions. * * @author Fabien Potencier + * + * @method AccessDecision getDecision(TokenInterface $token, array $attributes, mixed $object = null) */ interface AccessDecisionManagerInterface { diff --git a/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php b/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php index c748697c494f9..77e0ad6310b08 100644 --- a/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php +++ b/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php @@ -13,6 +13,7 @@ use Symfony\Component\Security\Core\Authentication\Token\NullToken; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; /** * AuthorizationChecker is the main authorization point of the Security component. @@ -31,6 +32,11 @@ public function __construct( } final public function isGranted(mixed $attribute, mixed $subject = null): bool + { + return $this->getDecision($attribute, $subject)->isGranted(); + } + + final public function getDecision($attribute, $subject = null): AccessDecision { $token = $this->tokenStorage->getToken(); @@ -38,6 +44,10 @@ final public function isGranted(mixed $attribute, mixed $subject = null): bool $token = new NullToken(); } - return $this->accessDecisionManager->decide($token, [$attribute], $subject); + if (!method_exists($this->accessDecisionManager, 'getDecision')) { + return new AccessDecision($this->accessDecisionManager->decide($token, [$attribute], $subject) ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED); + } + + return $this->accessDecisionManager->getDecision($token, [$attribute], $subject); } } diff --git a/src/Symfony/Component/Security/Core/Authorization/AuthorizationCheckerInterface.php b/src/Symfony/Component/Security/Core/Authorization/AuthorizationCheckerInterface.php index 6f5a6022178ba..0089b2b1dbf6e 100644 --- a/src/Symfony/Component/Security/Core/Authorization/AuthorizationCheckerInterface.php +++ b/src/Symfony/Component/Security/Core/Authorization/AuthorizationCheckerInterface.php @@ -15,6 +15,8 @@ * The AuthorizationCheckerInterface. * * @author Johannes M. Schmitt + * + * @method AccessDecision getDecision(mixed $attribute, mixed $subject = null) */ interface AuthorizationCheckerInterface { diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/AccessDecisionStrategyInterface.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/AccessDecisionStrategyInterface.php index 00238378a30fa..2e7f0cbfac5b5 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Strategy/AccessDecisionStrategyInterface.php +++ b/src/Symfony/Component/Security/Core/Authorization/Strategy/AccessDecisionStrategyInterface.php @@ -11,10 +11,14 @@ namespace Symfony\Component\Security\Core\Authorization\Strategy; +use Symfony\Component\Security\Core\Authorization\AccessDecision; + /** * A strategy for turning a stream of votes into a final decision. * * @author Alexander M. Turek + * + * @method AccessDecision getDecision(\Traversable $votes) */ interface AccessDecisionStrategyInterface { diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/AffirmativeStrategy.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/AffirmativeStrategy.php index fb316fd31e027..e15740de0c523 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Strategy/AffirmativeStrategy.php +++ b/src/Symfony/Component/Security/Core/Authorization/Strategy/AffirmativeStrategy.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Security\Core\Authorization\Strategy; +use Symfony\Component\Security\Core\Authorization\AccessDecision; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; /** @@ -31,22 +33,32 @@ public function __construct( public function decide(\Traversable $results): bool { + return $this->getDecision(new \ArrayIterator(array_map(fn ($vote) => new Vote($vote), iterator_to_array($results))))->isGranted(); + } + + public function getDecision(\Traversable $votes): AccessDecision + { + $currentVotes = []; $deny = 0; - foreach ($results as $result) { - if (VoterInterface::ACCESS_GRANTED === $result) { - return true; + + /** @var Vote $vote */ + foreach ($votes as $vote) { + $currentVotes[] = $vote; + + if ($vote->isGranted()) { + return new AccessDecision(VoterInterface::ACCESS_GRANTED, $currentVotes); } - if (VoterInterface::ACCESS_DENIED === $result) { + if ($vote->isDenied()) { ++$deny; } } if ($deny > 0) { - return false; + return new AccessDecision(VoterInterface::ACCESS_DENIED, $currentVotes); } - return $this->allowIfAllAbstainDecisions; + return new AccessDecision($this->allowIfAllAbstainDecisions ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, $currentVotes); } public function __toString(): string diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/ConsensusStrategy.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/ConsensusStrategy.php index bff56513830f3..3a77b28529b1e 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Strategy/ConsensusStrategy.php +++ b/src/Symfony/Component/Security/Core/Authorization/Strategy/ConsensusStrategy.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Security\Core\Authorization\Strategy; +use Symfony\Component\Security\Core\Authorization\AccessDecision; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; /** @@ -40,29 +42,38 @@ public function __construct( public function decide(\Traversable $results): bool { + return $this->getDecision(new \ArrayIterator(array_map(fn ($vote) => new Vote($vote), iterator_to_array($results))))->isGranted(); + } + + public function getDecision(\Traversable $votes): AccessDecision + { + $currentVotes = []; $grant = 0; $deny = 0; - foreach ($results as $result) { - if (VoterInterface::ACCESS_GRANTED === $result) { + + /** @var Vote $vote */ + foreach ($votes as $vote) { + $currentVotes[] = $vote; + if ($vote->isGranted()) { ++$grant; - } elseif (VoterInterface::ACCESS_DENIED === $result) { + } elseif ($vote->isDenied()) { ++$deny; } } if ($grant > $deny) { - return true; + return new AccessDecision(VoterInterface::ACCESS_GRANTED, $currentVotes); } if ($deny > $grant) { - return false; + return new AccessDecision(VoterInterface::ACCESS_DENIED, $currentVotes); } if ($grant > 0) { - return $this->allowIfEqualGrantedDeniedDecisions; + return new AccessDecision($this->allowIfEqualGrantedDeniedDecisions ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, $currentVotes); } - return $this->allowIfAllAbstainDecisions; + return new AccessDecision($this->allowIfAllAbstainDecisions ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, $currentVotes); } public function __toString(): string diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/PriorityStrategy.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/PriorityStrategy.php index d7f566adbf19d..8f0426a56d5cb 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Strategy/PriorityStrategy.php +++ b/src/Symfony/Component/Security/Core/Authorization/Strategy/PriorityStrategy.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Security\Core\Authorization\Strategy; +use Symfony\Component\Security\Core\Authorization\AccessDecision; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; /** @@ -32,17 +34,26 @@ public function __construct( public function decide(\Traversable $results): bool { - foreach ($results as $result) { - if (VoterInterface::ACCESS_GRANTED === $result) { - return true; + return $this->getDecision(new \ArrayIterator(array_map(fn ($vote) => new Vote($vote), iterator_to_array($results))))->isGranted(); + } + + public function getDecision(\Traversable $votes): AccessDecision + { + $currentVotes = []; + + /** @var Vote $vote */ + foreach ($votes as $vote) { + $currentVotes[] = $vote; + if ($vote->isGranted()) { + return new AccessDecision(VoterInterface::ACCESS_GRANTED, $currentVotes); } - if (VoterInterface::ACCESS_DENIED === $result) { - return false; + if ($vote->isDenied()) { + return new AccessDecision(VoterInterface::ACCESS_DENIED, $currentVotes); } } - return $this->allowIfAllAbstainDecisions; + return new AccessDecision($this->allowIfAllAbstainDecisions ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, $currentVotes); } public function __toString(): string diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/ScoringStrategy.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/ScoringStrategy.php new file mode 100644 index 0000000000000..b57ec73162e48 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Authorization/Strategy/ScoringStrategy.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Authorization\Strategy; + +use Symfony\Component\Security\Core\Authorization\AccessDecision; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; +use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; + +/** + * Grants access if vote results greated than 0. + * + * If all voters abstained from voting, the decision will be based on the + * allowIfAllAbstainDecisions property value (defaults to false). + * + * @author Roman JOLY + */ +final class ScoringStrategy implements AccessDecisionStrategyInterface, \Stringable +{ + public function __construct( + private bool $allowIfAllAbstainDecisions = false, + ) { + } + + public function decide(\Traversable $results): bool + { + return $this->getDecision(new \ArrayIterator(array_map(fn ($vote) => new Vote($vote), iterator_to_array($results))))->isGranted(); + } + + public function getDecision(\Traversable $votes): AccessDecision + { + $currentVotes = []; + $score = 0; + + /** @var Vote $vote */ + foreach ($votes as $vote) { + $currentVotes[] = $vote; + $score += $vote->getAccess(); + } + + if ($score > 0) { + return new AccessDecision(VoterInterface::ACCESS_GRANTED, $currentVotes, 'score = '.$score); + } + + if ($score < 0) { + return new AccessDecision(VoterInterface::ACCESS_DENIED, $currentVotes, 'score = '.$score); + } + + return new AccessDecision($this->allowIfAllAbstainDecisions ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, $currentVotes); + } + + public function __toString(): string + { + return 'scoring'; + } +} diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/UnanimousStrategy.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/UnanimousStrategy.php index d47d8994f86c4..6b3bba532c50e 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Strategy/UnanimousStrategy.php +++ b/src/Symfony/Component/Security/Core/Authorization/Strategy/UnanimousStrategy.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Security\Core\Authorization\Strategy; +use Symfony\Component\Security\Core\Authorization\AccessDecision; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; /** @@ -31,23 +33,33 @@ public function __construct( public function decide(\Traversable $results): bool { + return $this->getDecision(new \ArrayIterator(array_map(fn ($vote) => new Vote($vote), iterator_to_array($results))))->isGranted(); + } + + public function getDecision(\Traversable $votes): AccessDecision + { + $currentVotes = []; $grant = 0; - foreach ($results as $result) { - if (VoterInterface::ACCESS_DENIED === $result) { - return false; + + /** @var Vote $vote */ + foreach ($votes as $vote) { + $currentVotes[] = $vote; + + if ($vote->isDenied()) { + return new AccessDecision(VoterInterface::ACCESS_DENIED, $currentVotes); } - if (VoterInterface::ACCESS_GRANTED === $result) { + if ($vote->isGranted()) { ++$grant; } } // no deny votes if ($grant > 0) { - return true; + return new AccessDecision(VoterInterface::ACCESS_GRANTED, $currentVotes); } - return $this->allowIfAllAbstainDecisions; + return new AccessDecision($this->allowIfAllAbstainDecisions ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, $currentVotes); } public function __toString(): string diff --git a/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php b/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php index 0b82eb3a6d96d..ba920274b5095 100644 --- a/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php +++ b/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php @@ -13,6 +13,8 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Strategy\AccessDecisionStrategyInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; +use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; /** @@ -45,6 +47,25 @@ public function __construct( } } + public function getDecision(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false): AccessDecision + { + $currentDecisionLog = [ + 'attributes' => $attributes, + 'object' => $object, + 'voterDetails' => [], + ]; + + $this->currentLog[] = &$currentDecisionLog; + + $result = $this->manager->getDecision($token, $attributes, $object, $allowMultipleAttributes); + + $currentDecisionLog['result'] = $result; + + $this->decisionLog[] = array_pop($this->currentLog); // Using a stack since getDecision can be called by voters + + return $result; + } + public function decide(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false): bool { $currentDecisionLog = [ @@ -67,11 +88,15 @@ public function decide(TokenInterface $token, array $attributes, mixed $object = /** * Adds voter vote and class to the voter details. * - * @param array $attributes attributes used for the vote - * @param int $vote vote of the voter + * @param array $attributes attributes used for the vote + * @param VoteInterface|int $vote vote of the voter */ - public function addVoterVote(VoterInterface $voter, array $attributes, int $vote): void + public function addVoterVote(VoterInterface $voter, array $attributes, VoteInterface|int $vote): void { + if (!$vote instanceof Vote) { + $vote = new Vote($vote); + } + $currentLogIndex = \count($this->currentLog) - 1; $this->currentLog[$currentLogIndex]['voterDetails'][] = [ 'voter' => $voter, diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/AuthenticatedVoter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/AuthenticatedVoter.php index a073f6168472a..45aaa4ceabf8c 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/AuthenticatedVoter.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/AuthenticatedVoter.php @@ -41,12 +41,17 @@ public function __construct( } public function vote(TokenInterface $token, mixed $subject, array $attributes): int + { + return $this->getVote($token, $subject, $attributes)->getAccess(); + } + + public function getVote(TokenInterface $token, mixed $subject, array $attributes): VoteInterface { if ($attributes === [self::PUBLIC_ACCESS]) { - return VoterInterface::ACCESS_GRANTED; + return new Vote(VoterInterface::ACCESS_GRANTED); } - $result = VoterInterface::ACCESS_ABSTAIN; + $result = new Vote(VoterInterface::ACCESS_ABSTAIN); foreach ($attributes as $attribute) { if (null === $attribute || (self::IS_AUTHENTICATED_FULLY !== $attribute && self::IS_AUTHENTICATED_REMEMBERED !== $attribute @@ -60,29 +65,29 @@ public function vote(TokenInterface $token, mixed $subject, array $attributes): throw new InvalidArgumentException('Cannot decide on authentication attributes when an offline token is used.'); } - $result = VoterInterface::ACCESS_DENIED; + $result = new Vote(VoterInterface::ACCESS_DENIED); if (self::IS_AUTHENTICATED_FULLY === $attribute && $this->authenticationTrustResolver->isFullFledged($token)) { - return VoterInterface::ACCESS_GRANTED; + return new Vote(VoterInterface::ACCESS_GRANTED); } if (self::IS_AUTHENTICATED_REMEMBERED === $attribute && ($this->authenticationTrustResolver->isRememberMe($token) || $this->authenticationTrustResolver->isFullFledged($token))) { - return VoterInterface::ACCESS_GRANTED; + return new Vote(VoterInterface::ACCESS_GRANTED); } if (self::IS_AUTHENTICATED === $attribute && $this->authenticationTrustResolver->isAuthenticated($token)) { - return VoterInterface::ACCESS_GRANTED; + return new Vote(VoterInterface::ACCESS_GRANTED); } if (self::IS_REMEMBERED === $attribute && $this->authenticationTrustResolver->isRememberMe($token)) { - return VoterInterface::ACCESS_GRANTED; + return new Vote(VoterInterface::ACCESS_GRANTED); } if (self::IS_IMPERSONATOR === $attribute && $token instanceof SwitchUserToken) { - return VoterInterface::ACCESS_GRANTED; + return new Vote(VoterInterface::ACCESS_GRANTED); } } diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php index bab328307ac84..ccffca71a3b4b 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php @@ -46,7 +46,12 @@ public function supportsType(string $subjectType): bool public function vote(TokenInterface $token, mixed $subject, array $attributes): int { - $result = VoterInterface::ACCESS_ABSTAIN; + return $this->getVote($token, $subject, $attributes)->getAccess(); + } + + public function getVote(TokenInterface $token, mixed $subject, array $attributes): VoteInterface + { + $result = new Vote(VoterInterface::ACCESS_ABSTAIN); $variables = null; foreach ($attributes as $attribute) { if (!$attribute instanceof Expression) { @@ -55,9 +60,9 @@ public function vote(TokenInterface $token, mixed $subject, array $attributes): $variables ??= $this->getVariables($token, $subject); - $result = VoterInterface::ACCESS_DENIED; + $result = new Vote(VoterInterface::ACCESS_DENIED); if ($this->expressionLanguage->evaluate($attribute, $variables)) { - return VoterInterface::ACCESS_GRANTED; + return new Vote(VoterInterface::ACCESS_GRANTED); } } diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/RoleVoter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/RoleVoter.php index 3c65fb634c047..dccbfa4dbbc42 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/RoleVoter.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/RoleVoter.php @@ -27,7 +27,12 @@ public function __construct( public function vote(TokenInterface $token, mixed $subject, array $attributes): int { - $result = VoterInterface::ACCESS_ABSTAIN; + return $this->getVote($token, $subject, $attributes)->getAccess(); + } + + public function getVote(TokenInterface $token, mixed $subject, array $attributes): VoteInterface + { + $result = new Vote(VoterInterface::ACCESS_ABSTAIN); $roles = $this->extractRoles($token); foreach ($attributes as $attribute) { @@ -35,9 +40,9 @@ public function vote(TokenInterface $token, mixed $subject, array $attributes): continue; } - $result = VoterInterface::ACCESS_DENIED; + $result = new Vote(VoterInterface::ACCESS_DENIED); if (\in_array($attribute, $roles, true)) { - return VoterInterface::ACCESS_GRANTED; + return new Vote(VoterInterface::ACCESS_GRANTED); } } diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/TraceableVoter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/TraceableVoter.php index 1abc7c704fb59..364a6e6fcd8d5 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/TraceableVoter.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/TraceableVoter.php @@ -32,11 +32,20 @@ public function __construct( public function vote(TokenInterface $token, mixed $subject, array $attributes): int { - $result = $this->voter->vote($token, $subject, $attributes); + return $this->getVote($token, $subject, $attributes)->getAccess(); + } + + public function getVote(TokenInterface $token, mixed $subject, array $attributes): VoteInterface + { + if (method_exists($this->voter, 'getVote')) { + $vote = $this->voter->getVote($token, $subject, $attributes); + } else { + $vote = new Vote($this->voter->vote($token, $subject, $attributes)); + } - $this->eventDispatcher->dispatch(new VoteEvent($this->voter, $subject, $attributes, $result), 'debug.security.authorization.vote'); + $this->eventDispatcher->dispatch(new VoteEvent($this->voter, $subject, $attributes, $vote), 'debug.security.authorization.vote'); - return $result; + return $vote; } public function getDecoratedVoter(): VoterInterface diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php b/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php new file mode 100644 index 0000000000000..c657fab52f680 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Authorization\Voter; + +use Symfony\Component\Security\Core\Exception\InvalidArgumentException; + +/** + * A Vote is returned by a Voter and contains the access (granted, abstain or denied). + * It can also contain one or multiple messages explaining the vote decision. + * + * @author Dany Maillard + * @author Antoine Lamirault + * @author Roman JOLY + */ +class Vote implements VoteInterface +{ + /** + * @var string[] + */ + private array $messages; + + /** + * @param int $access One of the VoterInterface::ACCESS_* constants if scoring is false + */ + public function __construct(private int $access, string|array $messages = [], private array $context = [], private $scoring = false) + { + if (!$scoring && !\in_array($access, [VoterInterface::ACCESS_GRANTED, VoterInterface::ACCESS_ABSTAIN, VoterInterface::ACCESS_DENIED], true)) { + throw new \LogicException(\sprintf('"$access" must return one of "%s" constants ("ACCESS_GRANTED", "ACCESS_DENIED" or "ACCESS_ABSTAIN") when "$scoring" is false, "%s" returned.', VoterInterface::class, $access)); + } + $this->setMessages($messages); + } + + public function __debugInfo(): array + { + return [ + 'message' => $this->getMessage(), + 'context' => $this->context, + 'voteResultMessage' => $this->getVoteResultMessage(), + ]; + } + + public function getAccess(): int + { + return $this->access; + } + + public function isGranted(): bool + { + return true === $this->access || $this->access > 0; + } + + public function isAbstain(): bool + { + return VoterInterface::ACCESS_ABSTAIN === $this->access; + } + + public function isDenied(): bool + { + return false === $this->access || $this->access < 0; + } + + /** + * @param string|string[] $messages + */ + public function setMessages(string|array $messages): void + { + $this->messages = (array) $messages; + foreach ($this->messages as $message) { + if (!\is_string($message)) { + throw new InvalidArgumentException(\sprintf('Message must be string, "%s" given.', get_debug_type($message))); + } + } + } + + public function addMessage(string $message): void + { + $this->messages[] = $message; + } + + /** + * @return string[] + */ + public function getMessages(): array + { + return $this->messages; + } + + public function getMessage(): string + { + return implode(', ', $this->messages); + } + + public function getVoteResultMessage(): string + { + return $this->scoring ? 'SCORE : '.$this->access : match ($this->access) { + VoterInterface::ACCESS_GRANTED => 'ACCESS GRANTED', + VoterInterface::ACCESS_DENIED => 'ACCESS DENIED', + VoterInterface::ACCESS_ABSTAIN => 'ACCESS ABSTAIN', + default => 'UNKNOWN ACCESS TYPE', + }; + } + + public function getContext(): array + { + return $this->context; + } +} diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/VoteInterface.php b/src/Symfony/Component/Security/Core/Authorization/Voter/VoteInterface.php new file mode 100644 index 0000000000000..6b4eeb8c0240c --- /dev/null +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/VoteInterface.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Authorization\Voter; + +/** + * A VoteInterface Object contain information about vote, access/score, messages. + * + * @author Roman JOLY + */ +interface VoteInterface +{ + public function __construct(int $access, string|array $messages = [], array $context = []); + + public function __debugInfo(): array; + + public function getAccess(): int; + + public function isGranted(): bool; + + public function isAbstain(): bool; + + public function isDenied(): bool; + + public function getMessage(): string; + + public function getVoteResultMessage(): string; + + public function getContext(): array; +} diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php index 1f76a42eaf1b8..503e57df319d4 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php @@ -24,10 +24,10 @@ */ abstract class Voter implements VoterInterface, CacheableVoterInterface { - public function vote(TokenInterface $token, mixed $subject, array $attributes): int + public function getVote(TokenInterface $token, mixed $subject, array $attributes): VoteInterface { // abstain vote by default in case none of the attributes are supported - $vote = self::ACCESS_ABSTAIN; + $vote = new Vote(VoterInterface::ACCESS_ABSTAIN); foreach ($attributes as $attribute) { try { @@ -38,22 +38,38 @@ public function vote(TokenInterface $token, mixed $subject, array $attributes): if (str_contains($e->getMessage(), 'supports(): Argument #1')) { continue; } - throw $e; } // as soon as at least one attribute is supported, default is to deny access - $vote = self::ACCESS_DENIED; + if (!$vote->isDenied()) { + $vote = new Vote(VoterInterface::ACCESS_DENIED); + } + + $decision = $this->voteOnAttribute($attribute, $subject, $token); + + if (\is_bool($decision)) { + $decision = new Vote($decision); + } - if ($this->voteOnAttribute($attribute, $subject, $token)) { + if ($decision->isGranted()) { // grant access as soon as at least one attribute returns a positive response - return self::ACCESS_GRANTED; + return $decision; + } + + if ('' !== $decisionMessage = $decision->getMessage()) { + $vote->addMessage($decisionMessage); } } return $vote; } + public function vote(TokenInterface $token, mixed $subject, array $attributes): int + { + return $this->getVote($token, $subject, $attributes)->getAccess(); + } + /** * Return false if your voter doesn't support the given attribute. Symfony will cache * that decision and won't call your voter again for that attribute. @@ -91,5 +107,5 @@ abstract protected function supports(string $attribute, mixed $subject): bool; * @param TAttribute $attribute * @param TSubject $subject */ - abstract protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool; + abstract protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): VoteInterface|bool; } diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php b/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php index 5255c88e6ec0f..19e5f304eaaca 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php @@ -17,6 +17,8 @@ * VoterInterface is the interface implemented by all voters. * * @author Fabien Potencier + * + * method Vote getVote(TokenInterface $token, mixed $subject, array $attributes) */ interface VoterInterface { @@ -32,8 +34,6 @@ interface VoterInterface * * @param mixed $subject The subject to secure * @param array $attributes An array of attributes associated with the method being invoked - * - * @return self::ACCESS_* */ public function vote(TokenInterface $token, mixed $subject, array $attributes): int; } diff --git a/src/Symfony/Component/Security/Core/CHANGELOG.md b/src/Symfony/Component/Security/Core/CHANGELOG.md index 290aa8b25e566..d45119ce4c55b 100644 --- a/src/Symfony/Component/Security/Core/CHANGELOG.md +++ b/src/Symfony/Component/Security/Core/CHANGELOG.md @@ -17,6 +17,7 @@ CHANGELOG * Add `$token` argument to `UserCheckerInterface::checkPostAuth()` * Deprecate argument `$secret` of `RememberMeToken` * Deprecate returning an empty string in `UserInterface::getUserIdentifier()` + * Add the ability for voter to return decision reason and a score by passing a Vote object 7.0 --- diff --git a/src/Symfony/Component/Security/Core/Event/VoteEvent.php b/src/Symfony/Component/Security/Core/Event/VoteEvent.php index edc66b3667ec2..5b2c0ae0b01e8 100644 --- a/src/Symfony/Component/Security/Core/Event/VoteEvent.php +++ b/src/Symfony/Component/Security/Core/Event/VoteEvent.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Security\Core\Event; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; +use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Contracts\EventDispatcher\Event; @@ -27,8 +29,11 @@ public function __construct( private VoterInterface $voter, private mixed $subject, private array $attributes, - private int $vote, + private Vote|int $vote, ) { + if (!$vote instanceof Vote) { + $this->vote = new Vote($vote); + } } public function getVoter(): VoterInterface @@ -46,7 +51,7 @@ public function getAttributes(): array return $this->attributes; } - public function getVote(): int + public function getVote(): VoteInterface { return $this->vote; } diff --git a/src/Symfony/Component/Security/Core/Exception/AccessDeniedException.php b/src/Symfony/Component/Security/Core/Exception/AccessDeniedException.php index 93c3869470d05..1018e16e99ed2 100644 --- a/src/Symfony/Component/Security/Core/Exception/AccessDeniedException.php +++ b/src/Symfony/Component/Security/Core/Exception/AccessDeniedException.php @@ -12,6 +12,8 @@ namespace Symfony\Component\Security\Core\Exception; use Symfony\Component\HttpKernel\Attribute\WithHttpStatus; +use Symfony\Component\Security\Core\Authorization\AccessDecision; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; /** * AccessDeniedException is thrown when the account has not the required role. @@ -23,6 +25,7 @@ class AccessDeniedException extends RuntimeException { private array $attributes = []; private mixed $subject = null; + private ?AccessDecision $accessDecision = null; public function __construct(string $message = 'Access Denied.', ?\Throwable $previous = null, int $code = 403) { @@ -48,4 +51,29 @@ public function setSubject(mixed $subject): void { $this->subject = $subject; } + + /** + * Sets an access decision and appends the denied reasons to the exception message. + */ + public function setAccessDecision(AccessDecision $accessDecision): void + { + $this->accessDecision = $accessDecision; + if (!$deniedVotes = $accessDecision->getDeniedVotes()) { + return; + } + + $messages = array_map(static fn (Vote $vote): string => \sprintf('%s', $vote->getMessage()), $deniedVotes); + + if (!empty(array_filter($messages))) { + $this->message .= \sprintf(\PHP_EOL.'Decision message%s "%s"', \count($messages) > 1 ? 's are' : ' is', implode('" and "', $messages)); + } + } + + /** + * Gets the access decision. + */ + public function getAccessDecision(): ?AccessDecision + { + return $this->accessDecision; + } } diff --git a/src/Symfony/Component/Security/Core/Test/AccessDecisionStrategyTestCase.php b/src/Symfony/Component/Security/Core/Test/AccessDecisionStrategyTestCase.php index 792e777915400..1ee84b080c266 100644 --- a/src/Symfony/Component/Security/Core/Test/AccessDecisionStrategyTestCase.php +++ b/src/Symfony/Component/Security/Core/Test/AccessDecisionStrategyTestCase.php @@ -14,8 +14,10 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AccessDecisionManager; use Symfony\Component\Security\Core\Authorization\Strategy\AccessDecisionStrategyInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; /** @@ -31,12 +33,25 @@ abstract class AccessDecisionStrategyTestCase extends TestCase * @param VoterInterface[] $voters */ #[DataProvider('provideStrategyTests')] - final public function testDecide(AccessDecisionStrategyInterface $strategy, array $voters, bool $expected) + final public function testDecide(AccessDecisionStrategyInterface $strategy, array $voters, AccessDecision $expected) { $token = $this->createMock(TokenInterface::class); $manager = new AccessDecisionManager($voters, $strategy); - $this->assertSame($expected, $manager->decide($token, ['ROLE_FOO'])); + $this->assertSame($expected->isGranted(), $manager->decide($token, ['ROLE_FOO'])); + } + + /** + * @dataProvider provideStrategyTests + * + * @param VoterInterface[] $voters + */ + final public function testGetDecision(AccessDecisionStrategyInterface $strategy, array $voters, AccessDecision $expected) + { + $token = $this->createMock(TokenInterface::class); + $manager = new AccessDecisionManager($voters, $strategy); + + $this->assertEquals($expected, $manager->getDecision($token, ['ROLE_FOO'])); } /** @@ -52,12 +67,15 @@ final protected static function getVoters(int $grants, int $denies, int $abstain $voters = []; for ($i = 0; $i < $grants; ++$i) { $voters[] = static::getVoter(VoterInterface::ACCESS_GRANTED); + $voters[] = static::getVoterWithVoteObject(VoterInterface::ACCESS_GRANTED); } for ($i = 0; $i < $denies; ++$i) { $voters[] = static::getVoter(VoterInterface::ACCESS_DENIED); + $voters[] = static::getVoterWithVoteObject(VoterInterface::ACCESS_DENIED); } for ($i = 0; $i < $abstains; ++$i) { $voters[] = static::getVoter(VoterInterface::ACCESS_ABSTAIN); + $voters[] = static::getVoterWithVoteObject(VoterInterface::ACCESS_ABSTAIN); } return $voters; @@ -77,4 +95,30 @@ public function vote(TokenInterface $token, $subject, array $attributes): int } }; } + + final protected static function getVoterWithVoteObject(int $vote): VoterInterface + { + return new class($vote) implements VoterInterface { + public function __construct( + private int $vote, + ) { + } + + public function vote(TokenInterface $token, $subject, array $attributes): int + { + return $this->vote; + } + + public function getVote(TokenInterface $token, mixed $subject, array $attributes): Vote + { + return new Vote($this->vote); + } + }; + } + + final protected static function getAccessDecision(bool $decision, array $votes): AccessDecision + { + return new AccessDecision($decision ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, + array_map(fn ($v) => new Vote($v), $votes)); + } } diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php index f0a4978860df9..b2afd2a74666e 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php @@ -14,10 +14,13 @@ use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestCase; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AccessDecisionManager; use Symfony\Component\Security\Core\Authorization\Strategy\AccessDecisionStrategyInterface; use Symfony\Component\Security\Core\Authorization\Voter\CacheableVoterInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; +use Symfony\Component\Security\Core\Exception\LogicException; class AccessDecisionManagerTest extends TestCase { @@ -29,7 +32,40 @@ public function provideBadVoterResults(): array ]; } - public function testVoterCalls() + public function provideDataWithAndWithoutVoteObject() + { + yield [ + 'useVoteObject' => false, + 'decideFunction' => 'decide', + 'voteFunction' => 'vote', + 'excpectedCallback' => fn ($a) => $a, + ]; + + yield [ + 'useVoteObject' => true, + 'decideFunction' => 'getDecision', + 'voteFunction' => 'getVote', + 'excpectedCallback' => fn ($access, $votes = []) => new AccessDecision( + $access ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, + $votes + ), + ]; + } + + public function createVoterMock(bool $useVoteObject) + { + return $useVoteObject ? + $this->getMockBuilder(CacheableVoterInterface::class) + ->onlyMethods(['supportsAttribute', 'supportsType', 'vote']) + ->addMethods(['getVote']) + ->getMock(): + $this->createMock(CacheableVoterInterface::class); + } + + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testVoterCalls($useVoteObject, $decideFunction, $voteFunction, $excpectedCallback) { $token = $this->createMock(TokenInterface::class); @@ -39,36 +75,63 @@ public function testVoterCalls() $this->getUnexpectedVoter(), ]; - $strategy = new class implements AccessDecisionStrategyInterface { - public function decide(\Traversable $results): bool - { - $i = 0; - foreach ($results as $result) { - switch ($i++) { - case 0: - Assert::assertSame(VoterInterface::ACCESS_DENIED, $result); - - break; - case 1: - Assert::assertSame(VoterInterface::ACCESS_GRANTED, $result); - - return true; + if($useVoteObject) { + $strategy = new class() implements AccessDecisionStrategyInterface { + public function decide(\Traversable $results): bool { throw new LogicException('Method should not be called'); } // never call + public function getDecision(\Traversable $votes): AccessDecision + { + $i = 0; + foreach ($votes as $vote) { + switch ($i++) { + case 0: + Assert::assertSame(VoterInterface::ACCESS_DENIED, $vote->getAccess()); + + break; + case 1: + Assert::assertSame(VoterInterface::ACCESS_GRANTED, $vote->getAccess()); + + return new AccessDecision(VoterInterface::ACCESS_GRANTED); + } } + + return new AccessDecision(VoterInterface::ACCESS_DENIED); } + }; + } else { + $strategy = new class() implements AccessDecisionStrategyInterface { + public function decide(\Traversable $results): bool + { + $i = 0; + foreach ($results as $result) { + switch ($i++) { + case 0: + Assert::assertSame(VoterInterface::ACCESS_DENIED, $result); + + break; + case 1: + Assert::assertSame(VoterInterface::ACCESS_GRANTED, $result); + + return true; + } + } - return false; - } - }; + return false; + } + }; + } $manager = new AccessDecisionManager($voters, $strategy); - $this->assertTrue($manager->decide($token, ['ROLE_FOO'])); + $this->assertEquals($excpectedCallback(true), $manager->$decideFunction($token, ['ROLE_FOO'])); } - public function testCacheableVoters() + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testCacheableVoters($useVoteObject, $decideFunction, $voteFunction, $excpectedCallback) { $token = $this->createMock(TokenInterface::class); - $voter = $this->createMock(CacheableVoterInterface::class); + $voter = $this->createVoterMock($useVoteObject); $voter ->expects($this->once()) @@ -82,18 +145,22 @@ public function testCacheableVoters() ->willReturn(true); $voter ->expects($this->once()) - ->method('vote') + ->method($voteFunction) ->with($token, 'bar', ['foo']) - ->willReturn(VoterInterface::ACCESS_GRANTED); + ->willReturn($vote = ($useVoteObject ? new Vote(VoterInterface::ACCESS_GRANTED) : VoterInterface::ACCESS_GRANTED)); $manager = new AccessDecisionManager([$voter]); - $this->assertTrue($manager->decide($token, ['foo'], 'bar')); + $this->assertEquals($excpectedCallback(true, [$vote]), $manager->$decideFunction($token, ['foo'], 'bar')); } - public function testCacheableVotersIgnoresNonStringAttributes() + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testCacheableVotersIgnoresNonStringAttributes($useVoteObject, $decideFunction, $voteFunction, $excpectedCallback) { $token = $this->createMock(TokenInterface::class); - $voter = $this->createMock(CacheableVoterInterface::class); + $voter = $this->createVoterMock($useVoteObject); + $voter ->expects($this->never()) ->method('supportsAttribute'); @@ -104,18 +171,22 @@ public function testCacheableVotersIgnoresNonStringAttributes() ->willReturn(true); $voter ->expects($this->once()) - ->method('vote') + ->method($voteFunction) ->with($token, 'bar', [1337]) - ->willReturn(VoterInterface::ACCESS_GRANTED); + ->willReturn($vote = ($useVoteObject ? new Vote(VoterInterface::ACCESS_GRANTED) : VoterInterface::ACCESS_GRANTED)); $manager = new AccessDecisionManager([$voter]); - $this->assertTrue($manager->decide($token, [1337], 'bar')); + $this->assertEquals($excpectedCallback(true, [$vote]), $manager->$decideFunction($token, [1337], 'bar')); } - public function testCacheableVotersWithMultipleAttributes() + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testCacheableVotersWithMultipleAttributes($useVoteObject, $decideFunction, $voteFunction, $excpectedCallback) { $token = $this->createMock(TokenInterface::class); - $voter = $this->createMock(CacheableVoterInterface::class); + $voter = $this->createVoterMock($useVoteObject); + $voter ->expects($this->exactly(2)) ->method('supportsAttribute') @@ -137,18 +208,22 @@ public function testCacheableVotersWithMultipleAttributes() ->willReturn(true); $voter ->expects($this->once()) - ->method('vote') + ->method($voteFunction) ->with($token, 'bar', ['foo', 'bar']) - ->willReturn(VoterInterface::ACCESS_GRANTED); + ->willReturn($vote = ($useVoteObject ? new Vote(VoterInterface::ACCESS_GRANTED) : VoterInterface::ACCESS_GRANTED)); $manager = new AccessDecisionManager([$voter]); - $this->assertTrue($manager->decide($token, ['foo', 'bar'], 'bar', true)); + $this->assertEquals($excpectedCallback(true, [$vote]), $manager->$decideFunction($token, ['foo', 'bar'], 'bar', true)); } - public function testCacheableVotersWithEmptyAttributes() + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testCacheableVotersWithEmptyAttributes($useVoteObject, $decideFunction, $voteFunction, $excpectedCallback) { $token = $this->createMock(TokenInterface::class); - $voter = $this->createMock(CacheableVoterInterface::class); + $voter = $this->createVoterMock($useVoteObject); + $voter ->expects($this->never()) ->method('supportsAttribute'); @@ -159,18 +234,22 @@ public function testCacheableVotersWithEmptyAttributes() ->willReturn(true); $voter ->expects($this->once()) - ->method('vote') + ->method($voteFunction) ->with($token, 'bar', []) - ->willReturn(VoterInterface::ACCESS_GRANTED); + ->willReturn($vote = ($useVoteObject ? new Vote(VoterInterface::ACCESS_GRANTED) : VoterInterface::ACCESS_GRANTED)); $manager = new AccessDecisionManager([$voter]); - $this->assertTrue($manager->decide($token, [], 'bar')); + $this->assertEquals($excpectedCallback(true, [$vote]), $manager->$decideFunction($token, [], 'bar')); } - public function testCacheableVotersSupportsMethodsCalledOnce() + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testCacheableVotersSupportsMethodsCalledOnce($useVoteObject, $decideFunction, $voteFunction, $excpectedCallback) { $token = $this->createMock(TokenInterface::class); - $voter = $this->createMock(CacheableVoterInterface::class); + $voter = $this->createVoterMock($useVoteObject); + $voter ->expects($this->once()) ->method('supportsAttribute') @@ -183,19 +262,23 @@ public function testCacheableVotersSupportsMethodsCalledOnce() ->willReturn(true); $voter ->expects($this->exactly(2)) - ->method('vote') + ->method($voteFunction) ->with($token, 'bar', ['foo']) - ->willReturn(VoterInterface::ACCESS_GRANTED); + ->willReturn($vote = ($useVoteObject ? new Vote(VoterInterface::ACCESS_GRANTED) : VoterInterface::ACCESS_GRANTED)); $manager = new AccessDecisionManager([$voter]); - $this->assertTrue($manager->decide($token, ['foo'], 'bar')); - $this->assertTrue($manager->decide($token, ['foo'], 'bar')); + $this->assertEquals($excpectedCallback(true, [$vote]), $manager->$decideFunction($token, ['foo'], 'bar')); + $this->assertEquals($excpectedCallback(true, [$vote]), $manager->$decideFunction($token, ['foo'], 'bar')); } - public function testCacheableVotersNotCalled() + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testCacheableVotersNotCalled($useVoteObject, $decideFunction, $voteFunction, $excpectedCallback) { $token = $this->createMock(TokenInterface::class); - $voter = $this->createMock(CacheableVoterInterface::class); + $voter = $this->createVoterMock($useVoteObject); + $voter ->expects($this->once()) ->method('supportsAttribute') @@ -206,16 +289,20 @@ public function testCacheableVotersNotCalled() ->method('supportsType'); $voter ->expects($this->never()) - ->method('vote'); + ->method($voteFunction); $manager = new AccessDecisionManager([$voter]); - $this->assertFalse($manager->decide($token, ['foo'], 'bar')); + $this->assertEquals($excpectedCallback(false, []), $manager->$decideFunction($token, ['foo'], 'bar')); } - public function testCacheableVotersWithMultipleAttributesAndNonString() + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testCacheableVotersWithMultipleAttributesAndNonString($useVoteObject, $decideFunction, $voteFunction, $excpectedCallback) { $token = $this->createMock(TokenInterface::class); - $voter = $this->createMock(CacheableVoterInterface::class); + $voter = $this->createVoterMock($useVoteObject); + $voter ->expects($this->once()) ->method('supportsAttribute') @@ -229,12 +316,12 @@ public function testCacheableVotersWithMultipleAttributesAndNonString() ->willReturn(true); $voter ->expects($this->once()) - ->method('vote') + ->method($voteFunction) ->with($token, 'bar', ['foo', 1337]) - ->willReturn(VoterInterface::ACCESS_GRANTED); + ->willReturn($vote = ($useVoteObject ? new Vote(VoterInterface::ACCESS_GRANTED) : VoterInterface::ACCESS_GRANTED)); $manager = new AccessDecisionManager([$voter]); - $this->assertTrue($manager->decide($token, ['foo', 1337], 'bar', true)); + $this->assertEquals($excpectedCallback(true, [$vote]), $manager->$decideFunction($token, ['foo', 1337], 'bar', true)); } protected static function getVoters($grants, $denies, $abstains): array @@ -263,7 +350,7 @@ public function __construct(int $vote) $this->vote = $vote; } - public function vote(TokenInterface $token, $subject, array $attributes) + public function vote(TokenInterface $token, $subject, array $attributes): int { return $this->vote; } diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/AuthorizationCheckerTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/AuthorizationCheckerTest.php index 36b048c8976d1..a8b82684361b6 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/AuthorizationCheckerTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/AuthorizationCheckerTest.php @@ -16,46 +16,57 @@ use Symfony\Component\Security\Core\Authentication\Token\NullToken; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; use Symfony\Component\Security\Core\Authorization\AuthorizationChecker; +use Symfony\Component\Security\Core\Authorization\Voter\CacheableVoterInterface; +use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\User\InMemoryUser; class AuthorizationCheckerTest extends TestCase { - private MockObject&AccessDecisionManagerInterface $accessDecisionManager; - private AuthorizationChecker $authorizationChecker; private TokenStorage $tokenStorage; protected function setUp(): void { - $this->accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class); $this->tokenStorage = new TokenStorage(); - - $this->authorizationChecker = new AuthorizationChecker($this->tokenStorage, $this->accessDecisionManager); } - public function testVoteWithoutAuthenticationToken() + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testVoteWithoutAuthenticationToken($useVoteObject, $decideFunction, $voteFunction, $excpectedCallback) { - $authorizationChecker = new AuthorizationChecker($this->tokenStorage, $this->accessDecisionManager); + $accessDecisionManager = $this->createAccessDecisionManagerMock($useVoteObject); - $this->accessDecisionManager->expects($this->once())->method('decide')->with($this->isInstanceOf(NullToken::class))->willReturn(false); + $authorizationChecker = new AuthorizationChecker($this->tokenStorage, $accessDecisionManager); + + $accessDecisionManager->expects($this->once()) + ->method($decideFunction) + ->with($this->isInstanceOf(NullToken::class)) + ->willReturn($useVoteObject ? new AccessDecision(VoterInterface::ACCESS_DENIED) : false); $authorizationChecker->isGranted('ROLE_FOO'); } /** - * @dataProvider isGrantedProvider + * @dataProvider provideDataWithAndWithoutVoteObject */ - public function testIsGranted($decide) + public function testIsGranted($useVoteObject = null, $decideFunction = null, $voteFunction = null, $excpectedCallback=null) { - $token = new UsernamePasswordToken(new InMemoryUser('username', 'password', ['ROLE_USER']), 'provider', ['ROLE_USER']); + foreach([true, false] as $decision) { + $accessDecisionManager = $this->createAccessDecisionManagerMock($useVoteObject); + $authorizationChecker = new AuthorizationChecker($this->tokenStorage, $accessDecisionManager); - $this->accessDecisionManager - ->expects($this->once()) - ->method('decide') - ->willReturn($decide); - $this->tokenStorage->setToken($token); - $this->assertSame($decide, $this->authorizationChecker->isGranted('ROLE_FOO')); + $token = new UsernamePasswordToken(new InMemoryUser('username', 'password', ['ROLE_USER']), 'provider', ['ROLE_USER']); + + $accessDecisionManager + ->expects($this->once()) + ->method($decideFunction) + ->willReturn($useVoteObject ? new AccessDecision($decision ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED) : $decision); + $this->tokenStorage->setToken($token); + $this->assertSame($decision, $authorizationChecker->isGranted('ROLE_FOO')); + } } public static function isGrantedProvider() @@ -63,18 +74,55 @@ public static function isGrantedProvider() return [[true], [false]]; } - public function testIsGrantedWithObjectAttribute() + public function provideDataWithAndWithoutVoteObject() { + yield [ + 'useVoteObject' => false, + 'decideFunction' => 'decide', + 'voteFunction' => 'vote', + 'excpectedCallback' => fn ($a) => $a, + ]; + + yield [ + 'useVoteObject' => true, + 'decideFunction' => 'getDecision', + 'voteFunction' => 'getVote', + 'excpectedCallback' => fn ($access, $votes = []) => new AccessDecision( + $access ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, + $votes + ), + ]; + } + + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testIsGrantedWithObjectAttribute($useVoteObject, $decideFunction, $voteFunction, $excpectedCallback) + { + $accessDecisionManager = $this->createAccessDecisionManagerMock($useVoteObject); + $authorizationChecker = new AuthorizationChecker($this->tokenStorage, $accessDecisionManager); + $attribute = new \stdClass(); $token = new UsernamePasswordToken(new InMemoryUser('username', 'password', ['ROLE_USER']), 'provider', ['ROLE_USER']); - $this->accessDecisionManager + $accessDecisionManager ->expects($this->once()) - ->method('decide') + ->method($decideFunction) ->with($this->identicalTo($token), $this->identicalTo([$attribute])) - ->willReturn(true); + ->willReturn($useVoteObject ? new AccessDecision(VoterInterface::ACCESS_GRANTED) : true); $this->tokenStorage->setToken($token); - $this->assertTrue($this->authorizationChecker->isGranted($attribute)); + $this->assertTrue($authorizationChecker->isGranted($attribute)); + } + + + public function createAccessDecisionManagerMock(bool $useVoteObject) + { + return $useVoteObject ? + $this->getMockBuilder(AccessDecisionManagerInterface::class) + ->onlyMethods(['decide']) + ->addMethods(['getDecision']) + ->getMock(): + $this->createMock(AccessDecisionManagerInterface::class); } } diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/AffirmativeStrategyTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/AffirmativeStrategyTest.php index b467a920b0f67..d9bce53f71513 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/AffirmativeStrategyTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/AffirmativeStrategyTest.php @@ -13,6 +13,7 @@ use Symfony\Component\Security\Core\Authorization\Strategy\AffirmativeStrategy; use Symfony\Component\Security\Core\Test\AccessDecisionStrategyTestCase; +use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; class AffirmativeStrategyTest extends AccessDecisionStrategyTestCase { @@ -20,13 +21,26 @@ public static function provideStrategyTests(): iterable { $strategy = new AffirmativeStrategy(); - yield [$strategy, self::getVoters(1, 0, 0), true]; - yield [$strategy, self::getVoters(1, 2, 0), true]; - yield [$strategy, self::getVoters(0, 1, 0), false]; - yield [$strategy, self::getVoters(0, 0, 1), false]; + yield [$strategy, self::getVoters(1, 0, 0), self::getAccessDecision(true, [ + VoterInterface::ACCESS_GRANTED, + ])]; + yield [$strategy, self::getVoters(1, 2, 0), self::getAccessDecision(true, [ + VoterInterface::ACCESS_GRANTED, + ])]; + yield [$strategy, self::getVoters(0, 1, 0), self::getAccessDecision(false, [ + VoterInterface::ACCESS_DENIED, + VoterInterface::ACCESS_DENIED, + ])]; + yield [$strategy, self::getVoters(0, 0, 1), self::getAccessDecision(false, [ + VoterInterface::ACCESS_ABSTAIN, + VoterInterface::ACCESS_ABSTAIN, + ])]; $strategy = new AffirmativeStrategy(true); - yield [$strategy, self::getVoters(0, 0, 1), true]; + yield [$strategy, self::getVoters(0, 0, 1), self::getAccessDecision(true, [ + VoterInterface::ACCESS_ABSTAIN, + VoterInterface::ACCESS_ABSTAIN, + ])]; } } diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ConsensusStrategyTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ConsensusStrategyTest.php index bde6fb0d624b7..6db0c77d74a2d 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ConsensusStrategyTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ConsensusStrategyTest.php @@ -12,6 +12,7 @@ namespace Authorization\Strategy; use Symfony\Component\Security\Core\Authorization\Strategy\ConsensusStrategy; +use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Test\AccessDecisionStrategyTestCase; class ConsensusStrategyTest extends AccessDecisionStrategyTestCase @@ -20,21 +21,89 @@ public static function provideStrategyTests(): iterable { $strategy = new ConsensusStrategy(); - yield [$strategy, self::getVoters(1, 0, 0), true]; - yield [$strategy, self::getVoters(1, 2, 0), false]; - yield [$strategy, self::getVoters(2, 1, 0), true]; - yield [$strategy, self::getVoters(0, 0, 1), false]; + yield [$strategy, self::getVoters(1, 0, 0), self::getAccessDecision(true, [ + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_GRANTED, + ])]; - yield [$strategy, self::getVoters(2, 2, 0), true]; - yield [$strategy, self::getVoters(2, 2, 1), true]; + yield [$strategy, self::getVoters(1, 2, 0), self::getAccessDecision(false, [ + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_DENIED, + VoterInterface::ACCESS_DENIED, + VoterInterface::ACCESS_DENIED, + VoterInterface::ACCESS_DENIED, + ])]; + + yield [$strategy, self::getVoters(2, 1, 0), self::getAccessDecision(true, [ + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_DENIED, + VoterInterface::ACCESS_DENIED, + ])]; + + yield [$strategy, self::getVoters(0, 0, 1), self::getAccessDecision(false, [ + VoterInterface::ACCESS_ABSTAIN, + VoterInterface::ACCESS_ABSTAIN, + ])]; + + yield [$strategy, self::getVoters(2, 2, 0), self::getAccessDecision(true, [ + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_DENIED, + VoterInterface::ACCESS_DENIED, + VoterInterface::ACCESS_DENIED, + VoterInterface::ACCESS_DENIED, + ])]; + + yield [$strategy, self::getVoters(2, 2, 1), self::getAccessDecision(true, [ + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_DENIED, + VoterInterface::ACCESS_DENIED, + VoterInterface::ACCESS_DENIED, + VoterInterface::ACCESS_DENIED, + VoterInterface::ACCESS_ABSTAIN, + VoterInterface::ACCESS_ABSTAIN, + ])]; $strategy = new ConsensusStrategy(true); - yield [$strategy, self::getVoters(0, 0, 1), true]; + yield [$strategy, self::getVoters(0, 0, 1), self::getAccessDecision(true, [ + VoterInterface::ACCESS_ABSTAIN, + VoterInterface::ACCESS_ABSTAIN, + ])]; $strategy = new ConsensusStrategy(false, false); - yield [$strategy, self::getVoters(2, 2, 0), false]; - yield [$strategy, self::getVoters(2, 2, 1), false]; + yield [$strategy, self::getVoters(2, 2, 0), self::getAccessDecision(false, [ + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_DENIED, + VoterInterface::ACCESS_DENIED, + VoterInterface::ACCESS_DENIED, + VoterInterface::ACCESS_DENIED, + ])]; + + yield [$strategy, self::getVoters(2, 2, 1), self::getAccessDecision(false, [ + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_DENIED, + VoterInterface::ACCESS_DENIED, + VoterInterface::ACCESS_DENIED, + VoterInterface::ACCESS_DENIED, + VoterInterface::ACCESS_ABSTAIN, + VoterInterface::ACCESS_ABSTAIN, + ])]; } } diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/PriorityStrategyTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/PriorityStrategyTest.php index aef3aaf0b27e3..43e3e724ffa07 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/PriorityStrategyTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/PriorityStrategyTest.php @@ -26,19 +26,35 @@ public static function provideStrategyTests(): iterable self::getVoter(VoterInterface::ACCESS_GRANTED), self::getVoter(VoterInterface::ACCESS_DENIED), self::getVoter(VoterInterface::ACCESS_DENIED), - ], true]; + ], self::getAccessDecision(true, [ + VoterInterface::ACCESS_ABSTAIN, + VoterInterface::ACCESS_GRANTED, + ])]; yield [$strategy, [ self::getVoter(VoterInterface::ACCESS_ABSTAIN), self::getVoter(VoterInterface::ACCESS_DENIED), self::getVoter(VoterInterface::ACCESS_GRANTED), self::getVoter(VoterInterface::ACCESS_GRANTED), - ], false]; - - yield [$strategy, self::getVoters(0, 0, 2), false]; + ], self::getAccessDecision(false, [ + VoterInterface::ACCESS_ABSTAIN, + VoterInterface::ACCESS_DENIED, + ])]; + + yield [$strategy, self::getVoters(0, 0, 2), self::getAccessDecision(false, [ + VoterInterface::ACCESS_ABSTAIN, + VoterInterface::ACCESS_ABSTAIN, + VoterInterface::ACCESS_ABSTAIN, + VoterInterface::ACCESS_ABSTAIN + ])]; $strategy = new PriorityStrategy(true); - yield [$strategy, self::getVoters(0, 0, 2), true]; + yield [$strategy, self::getVoters(0, 0, 2), self::getAccessDecision(true, [ + VoterInterface::ACCESS_ABSTAIN, + VoterInterface::ACCESS_ABSTAIN, + VoterInterface::ACCESS_ABSTAIN, + VoterInterface::ACCESS_ABSTAIN, + ])]; } } diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ScoringStrategyTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ScoringStrategyTest.php new file mode 100644 index 0000000000000..fdd66001e9491 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ScoringStrategyTest.php @@ -0,0 +1,153 @@ +createMock(TokenInterface::class); + $manager = new AccessDecisionManager($voters, $strategy); + + $this->assertEquals($expected, $manager->getDecision($token, ['ROLE_FOO'])); + } + + public static function provideStrategyTests(): iterable + { + $strategy = new ScoringStrategy(); + + yield [$strategy, [ + self::getVoter(VoterInterface::ACCESS_DENIED), + self::getVoter(VoterInterface::ACCESS_ABSTAIN), + ], self::getAccessDecision(false, [ + [VoterInterface::ACCESS_DENIED, false], + [VoterInterface::ACCESS_ABSTAIN, false], + ], 'score = -1' + )]; + + yield [$strategy, [ + self::getVoterWithVoteObject(VoterInterface::ACCESS_DENIED), + self::getVoter(VoterInterface::ACCESS_ABSTAIN), + ], self::getAccessDecision(false, [ + [VoterInterface::ACCESS_DENIED, false], + [VoterInterface::ACCESS_ABSTAIN, false], + ], 'score = -1' + )]; + + yield [$strategy, [ + self::getVoterWithVoteObjectAndScoring(5), + ], self::getAccessDecision(true, [ + [5, true], + ], 'score = 5' + )]; + + yield [$strategy, [ + self::getVoter(VoterInterface::ACCESS_DENIED), + self::getVoter(VoterInterface::ACCESS_DENIED), + self::getVoter(VoterInterface::ACCESS_DENIED), + self::getVoterWithVoteObjectAndScoring(VoterInterface::ACCESS_ABSTAIN), + self::getVoterWithVoteObjectAndScoring(2), + ], self::getAccessDecision(false, [ + [VoterInterface::ACCESS_DENIED, false], + [VoterInterface::ACCESS_DENIED, false], + [VoterInterface::ACCESS_DENIED, false], + [VoterInterface::ACCESS_ABSTAIN, true], + [2, true], + ], 'score = -1' + )]; + + yield [$strategy, [ + self::getVoter(VoterInterface::ACCESS_DENIED), + self::getVoter(VoterInterface::ACCESS_DENIED), + self::getVoter(VoterInterface::ACCESS_DENIED), + self::getVoter(VoterInterface::ACCESS_DENIED), + self::getVoterWithVoteObjectAndScoring(5), + ], self::getAccessDecision(true, [ + [VoterInterface::ACCESS_DENIED, false], + [VoterInterface::ACCESS_DENIED, false], + [VoterInterface::ACCESS_DENIED, false], + [VoterInterface::ACCESS_DENIED, false], + [5, true], + ], 'score = 1' + )]; + } + + + final protected static function getVoter(int $vote): VoterInterface + { + return new class($vote) implements VoterInterface { + public function __construct( + private int $vote, + ) { + } + + public function vote(TokenInterface $token, $subject, array $attributes): int + { + return $this->vote; + } + }; + } + + final protected static function getVoterWithVoteObject(int $vote): VoterInterface + { + return new class($vote) implements VoterInterface { + public function __construct( + private int $vote, + ) { + } + + public function vote(TokenInterface $token, $subject, array $attributes): int + { + return $this->vote; + } + + public function getVote(TokenInterface $token, mixed $subject, array $attributes): Vote + { + return new Vote($this->vote); + } + }; + } + + final protected static function getVoterWithVoteObjectAndScoring(int $vote): VoterInterface + { + return new class($vote) implements VoterInterface { + public function __construct( + private int $vote, + ) { + } + + public function vote(TokenInterface $token, $subject, array $attributes): int + { + return $this->vote; + } + + public function getVote(TokenInterface $token, mixed $subject, array $attributes): Vote + { + return new Vote($this->vote, scoring: true); + } + }; + } + + final protected static function getAccessDecision(bool $decision, array $votes, string $message): AccessDecision + { + return new AccessDecision($decision ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, + array_map(fn ($vote) => new Vote($vote[0], scoring: $vote[1]), $votes), + $message + ); + } +} diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/UnanimousStrategyTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/UnanimousStrategyTest.php index e00a50e3186ba..52c28188cced4 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/UnanimousStrategyTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/UnanimousStrategyTest.php @@ -12,6 +12,7 @@ namespace Authorization\Strategy; use Symfony\Component\Security\Core\Authorization\Strategy\UnanimousStrategy; +use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Test\AccessDecisionStrategyTestCase; class UnanimousStrategyTest extends AccessDecisionStrategyTestCase @@ -20,14 +21,36 @@ public static function provideStrategyTests(): iterable { $strategy = new UnanimousStrategy(); - yield [$strategy, self::getVoters(1, 0, 0), true]; - yield [$strategy, self::getVoters(1, 0, 1), true]; - yield [$strategy, self::getVoters(1, 1, 0), false]; - - yield [$strategy, self::getVoters(0, 0, 2), false]; + yield [$strategy, self::getVoters(1, 0, 0), self::getAccessDecision(true, [ + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_GRANTED, + ])]; + yield [$strategy, self::getVoters(1, 0, 1), self::getAccessDecision(true, [ + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_ABSTAIN, + VoterInterface::ACCESS_ABSTAIN, + ])]; + yield [$strategy, self::getVoters(1, 1, 0), self::getAccessDecision(false, [ + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_GRANTED, + VoterInterface::ACCESS_DENIED, + ])]; + + yield [$strategy, self::getVoters(0, 0, 2), self::getAccessDecision(false, [ + VoterInterface::ACCESS_ABSTAIN, + VoterInterface::ACCESS_ABSTAIN, + VoterInterface::ACCESS_ABSTAIN, + VoterInterface::ACCESS_ABSTAIN, + ])]; $strategy = new UnanimousStrategy(true); - yield [$strategy, self::getVoters(0, 0, 2), true]; + yield [$strategy, self::getVoters(0, 0, 2), self::getAccessDecision(true, [ + VoterInterface::ACCESS_ABSTAIN, + VoterInterface::ACCESS_ABSTAIN, + VoterInterface::ACCESS_ABSTAIN, + VoterInterface::ACCESS_ABSTAIN, + ])]; } } diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerTest.php index 8797d74d79f0c..da9d18a43f18d 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerTest.php @@ -16,6 +16,7 @@ use Symfony\Component\Security\Core\Authorization\AccessDecisionManager; use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; use Symfony\Component\Security\Core\Authorization\TraceableAccessDecisionManager; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Tests\Fixtures\DummyVoter; @@ -61,8 +62,8 @@ public static function provideObjectsAndLogs(): \Generator 'object' => null, 'result' => true, 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1'], 'vote' => VoterInterface::ACCESS_GRANTED], - ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1'], 'vote' => VoterInterface::ACCESS_GRANTED], + ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], + ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], ], ]], ['ATTRIBUTE_1'], @@ -79,8 +80,8 @@ public static function provideObjectsAndLogs(): \Generator 'object' => true, 'result' => false, 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_ABSTAIN], - ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_GRANTED], + ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], ], ]], ['ATTRIBUTE_1', 'ATTRIBUTE_2'], @@ -97,8 +98,8 @@ public static function provideObjectsAndLogs(): \Generator 'object' => 'jolie string', 'result' => false, 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => [null], 'vote' => VoterInterface::ACCESS_ABSTAIN], - ['voter' => $voter2, 'attributes' => [null], 'vote' => VoterInterface::ACCESS_DENIED], + ['voter' => $voter1, 'attributes' => [null], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ['voter' => $voter2, 'attributes' => [null], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], ], ]], [null], @@ -139,8 +140,8 @@ public static function provideObjectsAndLogs(): \Generator 'object' => $x = [], 'result' => false, 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_ABSTAIN], - ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_ABSTAIN], + ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], ], ]], ['ATTRIBUTE_2'], @@ -157,8 +158,8 @@ public static function provideObjectsAndLogs(): \Generator 'object' => new \stdClass(), 'result' => false, 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => [12.13], 'vote' => VoterInterface::ACCESS_DENIED], - ['voter' => $voter2, 'attributes' => [12.13], 'vote' => VoterInterface::ACCESS_DENIED], + ['voter' => $voter1, 'attributes' => [12.13], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], + ['voter' => $voter2, 'attributes' => [12.13], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], ], ]], [12.13], @@ -242,7 +243,9 @@ public function testAccessDecisionManagerCalledByVoter() 'attributes' => ['attr1'], 'object' => null, 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['attr1'], 'vote' => VoterInterface::ACCESS_GRANTED], + ['voter' => $voter1, 'attributes' => ['attr1'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], + ['voter' => $voter2, 'attributes' => ['attr1'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ['voter' => $voter3, 'attributes' => ['attr1'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], ], 'result' => true, ], @@ -250,8 +253,9 @@ public function testAccessDecisionManagerCalledByVoter() 'attributes' => ['attr2'], 'object' => null, 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_ABSTAIN], - ['voter' => $voter2, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_GRANTED], + ['voter' => $voter1, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ['voter' => $voter2, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], + ['voter' => $voter3, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], ], 'result' => true, ], @@ -259,9 +263,9 @@ public function testAccessDecisionManagerCalledByVoter() 'attributes' => ['attr2'], 'object' => $obj, 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_ABSTAIN], - ['voter' => $voter2, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_DENIED], - ['voter' => $voter3, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_GRANTED], + ['voter' => $voter1, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ['voter' => $voter2, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], + ['voter' => $voter3, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], ], 'result' => true, ], diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerWithVoteObjectTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerWithVoteObjectTest.php new file mode 100644 index 0000000000000..5c54b0de0ff11 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerWithVoteObjectTest.php @@ -0,0 +1,295 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Tests\Authorization; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\AccessDecision; +use Symfony\Component\Security\Core\Authorization\AccessDecisionManager; +use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; +use Symfony\Component\Security\Core\Authorization\TraceableAccessDecisionManager; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; +use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; +use Symfony\Component\Security\Core\Tests\Fixtures\DummyVoter; + +class TraceableAccessDecisionManagerWithVoteObjectTest extends TestCase +{ + /** + * @dataProvider provideObjectsAndLogs + */ + public function testDecideLog(array $expectedLog, array $attributes, $object, array $voterVotes, AccessDecision $decision) + { + $token = $this->createMock(TokenInterface::class); + $admMock = $this + ->getMockBuilder(AccessDecisionManagerInterface::class) + ->onlyMethods(['decide']) + ->addMethods(['getDecision']) + ->getMock(); + + $adm = new TraceableAccessDecisionManager($admMock); + + $admMock + ->expects($this->once()) + ->method('getDecision') + ->with($token, $attributes, $object) + ->willReturnCallback(function ($token, $attributes, $object) use ($voterVotes, $adm, $decision) { + foreach ($voterVotes as $voterVote) { + [$voter, $vote] = $voterVote; + $adm->addVoterVote($voter, $attributes, $vote); + } + + return $decision; + }) + ; + + $adm->getDecision($token, $attributes, $object); + + $this->assertEquals($expectedLog, $adm->getDecisionLog()); + } + + public static function provideObjectsAndLogs(): \Generator + { + $voter1 = new DummyVoter(); + $voter2 = new DummyVoter(); + + yield [ + [[ + 'attributes' => ['ATTRIBUTE_1'], + 'object' => null, + 'result' => $result = new AccessDecision(VoterInterface::ACCESS_GRANTED), + 'voterDetails' => [ + ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], + ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], + ], + ]], + ['ATTRIBUTE_1'], + null, + [ + [$voter1, VoterInterface::ACCESS_GRANTED], + [$voter2, VoterInterface::ACCESS_GRANTED], + ], + $result, + ]; + yield [ + [[ + 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], + 'object' => true, + 'result' => $result = new AccessDecision(VoterInterface::ACCESS_DENIED), + 'voterDetails' => [ + ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], + ], + ]], + ['ATTRIBUTE_1', 'ATTRIBUTE_2'], + true, + [ + [$voter1, VoterInterface::ACCESS_ABSTAIN], + [$voter2, VoterInterface::ACCESS_GRANTED], + ], + $result, + ]; + yield [ + [[ + 'attributes' => [null], + 'object' => 'jolie string', + 'result' => $result = new AccessDecision(VoterInterface::ACCESS_DENIED), + 'voterDetails' => [ + ['voter' => $voter1, 'attributes' => [null], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ['voter' => $voter2, 'attributes' => [null], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], + ], + ]], + [null], + 'jolie string', + [ + [$voter1, VoterInterface::ACCESS_ABSTAIN], + [$voter2, VoterInterface::ACCESS_DENIED], + ], + $result, + ]; + yield [ + [[ + 'attributes' => [12], + 'object' => 12345, + 'result' => $result = new AccessDecision(VoterInterface::ACCESS_GRANTED), + 'voterDetails' => [], + ]], + 'attributes' => [12], + 12345, + [], + $result, + ]; + yield [ + [[ + 'attributes' => [new \stdClass()], + 'object' => $x = fopen(__FILE__, 'r'), + 'result' => $result = new AccessDecision(VoterInterface::ACCESS_GRANTED), + 'voterDetails' => [], + ]], + [new \stdClass()], + $x, + [], + $result, + ]; + yield [ + [[ + 'attributes' => ['ATTRIBUTE_2'], + 'object' => $x = [], + 'result' => $result = new AccessDecision(VoterInterface::ACCESS_DENIED), + 'voterDetails' => [ + ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ], + ]], + ['ATTRIBUTE_2'], + $x, + [ + [$voter1, VoterInterface::ACCESS_ABSTAIN], + [$voter2, VoterInterface::ACCESS_ABSTAIN], + ], + $result, + ]; + yield [ + [[ + 'attributes' => [12.13], + 'object' => new \stdClass(), + 'result' => $result = new AccessDecision(VoterInterface::ACCESS_DENIED), + 'voterDetails' => [ + ['voter' => $voter1, 'attributes' => [12.13], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], + ['voter' => $voter2, 'attributes' => [12.13], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], + ], + ]], + [12.13], + new \stdClass(), + [ + [$voter1, VoterInterface::ACCESS_DENIED], + [$voter2, VoterInterface::ACCESS_DENIED], + ], + $result, + ]; + } + + /** + * Tests decision log returned when a voter call decide method of AccessDecisionManager. + */ + public function testAccessDecisionManagerCalledByVoter() + { + $voter1 = $this + ->getMockBuilder(VoterInterface::class) + ->onlyMethods(['vote']) + ->addMethods(['getVote']) + ->getMock(); + + $voter2 = $this + ->getMockBuilder(VoterInterface::class) + ->onlyMethods(['vote']) + ->addMethods(['getVote']) + ->getMock(); + + $voter3 = $this + ->getMockBuilder(VoterInterface::class) + ->onlyMethods(['vote']) + ->addMethods(['getVote']) + ->getMock(); + + $sut = new TraceableAccessDecisionManager(new AccessDecisionManager([$voter1, $voter2, $voter3])); + + $voter1 + ->expects($this->any()) + ->method('getVote') + ->willReturnCallback(function (TokenInterface $token, $subject, array $attributes) use ($sut, $voter1) { + $vote = new Vote(\in_array('attr1', $attributes, true) ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_ABSTAIN); + $sut->addVoterVote($voter1, $attributes, $vote); + + return $vote; + }); + + $voter2 + ->expects($this->any()) + ->method('getVote') + ->willReturnCallback(function (TokenInterface $token, $subject, array $attributes) use ($sut, $voter2) { + if (\in_array('attr2', $attributes, true)) { + $vote = new Vote((null == $subject) ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED); + } else { + $vote = new Vote(VoterInterface::ACCESS_ABSTAIN); + } + + $sut->addVoterVote($voter2, $attributes, $vote); + + return $vote; + }); + + $voter3 + ->expects($this->any()) + ->method('getVote') + ->willReturnCallback(function (TokenInterface $token, $subject, array $attributes) use ($sut, $voter3) { + if (\in_array('attr2', $attributes, true) && $subject) { + $vote = new Vote($sut->getDecision($token, $attributes)->isGranted() ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED); + } else { + $vote = new Vote(VoterInterface::ACCESS_ABSTAIN); + } + + $sut->addVoterVote($voter3, $attributes, $vote); + + return $vote; + }); + + $token = $this->createMock(TokenInterface::class); + $sut->getDecision($token, ['attr1'], null); + $sut->getDecision($token, ['attr2'], $obj = new \stdClass()); + + $this->assertEquals([ + [ + 'attributes' => ['attr1'], + 'object' => null, + 'voterDetails' => [ + ['voter' => $voter1, 'attributes' => ['attr1'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], + ], + 'result' => new AccessDecision(VoterInterface::ACCESS_GRANTED, [new Vote(VoterInterface::ACCESS_GRANTED)]), + ], + [ + 'attributes' => ['attr2'], + 'object' => null, + 'voterDetails' => [ + ['voter' => $voter1, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ['voter' => $voter2, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], + ], + 'result' => new AccessDecision(VoterInterface::ACCESS_GRANTED, [ + new Vote(VoterInterface::ACCESS_ABSTAIN), + new Vote(VoterInterface::ACCESS_GRANTED), + ]), + ], + [ + 'attributes' => ['attr2'], + 'object' => $obj, + 'voterDetails' => [ + ['voter' => $voter1, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ['voter' => $voter2, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], + ['voter' => $voter3, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], + ], + 'result' => new AccessDecision(VoterInterface::ACCESS_GRANTED, [ + new Vote(VoterInterface::ACCESS_ABSTAIN), + new Vote(VoterInterface::ACCESS_DENIED), + new Vote(VoterInterface::ACCESS_GRANTED), + ]), + ], + ], $sut->getDecisionLog()); + } + + public function testCustomAccessDecisionManagerReturnsEmptyStrategy() + { + $admMock = $this->createMock(AccessDecisionManagerInterface::class); + + $adm = new TraceableAccessDecisionManager($admMock); + + $this->assertEquals('-', $adm->getStrategy()); + } +} diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/AuthenticatedVoterTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/AuthenticatedVoterTest.php index 89f6c35007520..830c8baa06880 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/AuthenticatedVoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/AuthenticatedVoterTest.php @@ -35,6 +35,16 @@ public function testVote($authenticated, $attributes, $expected) $this->assertSame($expected, $voter->vote($this->getToken($authenticated), null, $attributes)); } + /** + * @dataProvider getVoteTests + */ + public function testGetVote($authenticated, $attributes, $expected) + { + $voter = new AuthenticatedVoter(new AuthenticationTrustResolver()); + + $this->assertSame($expected, $voter->getVote($this->getToken($authenticated), null, $attributes)->getAccess()); + } + public static function getVoteTests() { return [ diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/ExpressionVoterTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/ExpressionVoterTest.php index 369b17f0460ea..a1b3d43309124 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/ExpressionVoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/ExpressionVoterTest.php @@ -32,6 +32,16 @@ public function testVoteWithTokenThatReturnsRoleNames($roles, $attributes, $expe $this->assertSame($expected, $voter->vote($this->getTokenWithRoleNames($roles, $tokenExpectsGetRoles), null, $attributes)); } + /** + * @dataProvider getVoteTests + */ + public function testGetVoteWithTokenThatReturnsRoleNames($roles, $attributes, $expected, $tokenExpectsGetRoles = true, $expressionLanguageExpectsEvaluate = true) + { + $voter = new ExpressionVoter($this->createExpressionLanguage($expressionLanguageExpectsEvaluate), $this->createTrustResolver(), $this->createAuthorizationChecker()); + + $this->assertSame($expected, $voter->getVote($this->getTokenWithRoleNames($roles, $tokenExpectsGetRoles), null, $attributes)->getAccess()); + } + public static function getVoteTests() { return [ diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleHierarchyVoterTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleHierarchyVoterTest.php index b811bd745bb85..e0a4e61ae3d61 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleHierarchyVoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleHierarchyVoterTest.php @@ -27,6 +27,16 @@ public function testVoteUsingTokenThatReturnsRoleNames($roles, $attributes, $exp $this->assertSame($expected, $voter->vote($this->getTokenWithRoleNames($roles), null, $attributes)); } + /** + * @dataProvider getVoteTests + */ + public function testGetVoteUsingTokenThatReturnsRoleNames($roles, $attributes, $expected) + { + $voter = new RoleHierarchyVoter(new RoleHierarchy(['ROLE_FOO' => ['ROLE_FOOBAR']])); + + $this->assertSame($expected, $voter->getVote($this->getTokenWithRoleNames($roles), null, $attributes)->getAccess()); + } + public static function getVoteTests() { return array_merge(parent::getVoteTests(), [ @@ -44,6 +54,16 @@ public function testVoteWithEmptyHierarchyUsingTokenThatReturnsRoleNames($roles, $this->assertSame($expected, $voter->vote($this->getTokenWithRoleNames($roles), null, $attributes)); } + /** + * @dataProvider getVoteWithEmptyHierarchyTests + */ + public function testGetVoteWithEmptyHierarchyUsingTokenThatReturnsRoleNames($roles, $attributes, $expected) + { + $voter = new RoleHierarchyVoter(new RoleHierarchy([])); + + $this->assertSame($expected, $voter->getVote($this->getTokenWithRoleNames($roles), null, $attributes)->getAccess()); + } + public static function getVoteWithEmptyHierarchyTests() { return parent::getVoteTests(); diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleVoterTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleVoterTest.php index dfa0555652fba..109d2d5f0f5b2 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleVoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleVoterTest.php @@ -30,6 +30,16 @@ public function testVoteUsingTokenThatReturnsRoleNames($roles, $attributes, $exp $this->assertSame($expected, $voter->vote($this->getTokenWithRoleNames($roles), null, $attributes)); } + /** + * @dataProvider getVoteTests + */ + public function testGetVoteUsingTokenThatReturnsRoleNames($roles, $attributes, $expected) + { + $voter = new RoleVoter(); + + $this->assertSame($expected, $voter->getVote($this->getTokenWithRoleNames($roles), null, $attributes)->getAccess()); + } + public static function getVoteTests() { return [ diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/TraceableVoterTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/TraceableVoterTest.php index 1d8c86490de4e..a2245931bf9f8 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/TraceableVoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/TraceableVoterTest.php @@ -53,6 +53,30 @@ public function testVote() $this->assertSame(VoterInterface::ACCESS_DENIED, $result); } + public function testGetVote() + { + $voter = $this->createMock(VoterInterface::class); + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $token = $this->createStub(TokenInterface::class); + + $voter + ->expects($this->once()) + ->method('vote') + ->with($token, 'anysubject', ['attr1']) + ->willReturn(VoterInterface::ACCESS_DENIED); + + $eventDispatcher + ->expects($this->once()) + ->method('dispatch') + ->with(new VoteEvent($voter, 'anysubject', ['attr1'], VoterInterface::ACCESS_DENIED), 'debug.security.authorization.vote'); + + $sut = new TraceableVoter($voter, $eventDispatcher); + $result = $sut->getVote($token, 'anysubject', ['attr1']); + + $this->assertSame(VoterInterface::ACCESS_DENIED, $result->getAccess()); + } + public function testSupportsAttributeOnCacheable() { $voter = $this->createMock(CacheableVoterInterface::class); diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoterTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoterTest.php index 602c61ab08a34..34a820b081069 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoterTest.php @@ -63,12 +63,27 @@ public function testVote(VoterInterface $voter, array $attributes, $expectedVote $this->assertEquals($expectedVote, $voter->vote($this->token, $object, $attributes), $message); } + /** + * @dataProvider getTests + */ + public function testGetVote(VoterInterface $voter, array $attributes, $expectedVote, $object, $message) + { + $this->assertEquals($expectedVote, $voter->getVote($this->token, $object, $attributes)->getAccess(), $message); + } + public function testVoteWithTypeError() { $this->expectException(\TypeError::class); $this->expectExceptionMessage('Should error'); (new TypeErrorVoterTest_Voter())->vote($this->token, new \stdClass(), ['EDIT']); } + + public function testGetVoteWithTypeError() + { + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Should error'); + (new TypeErrorVoterTest_Voter())->getVote($this->token, new \stdClass(), ['EDIT']); + } } class VoterTest_Voter extends Voter diff --git a/src/Symfony/Component/Security/Core/Tests/Exception/AccessDeniedExceptionTest.php b/src/Symfony/Component/Security/Core/Tests/Exception/AccessDeniedExceptionTest.php new file mode 100644 index 0000000000000..55c5ffd824a46 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Exception/AccessDeniedExceptionTest.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Tests\Exception; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Security\Core\Authorization\AccessDecision; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; +use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; + +final class AccessDeniedExceptionTest extends TestCase +{ + /** + * @dataProvider getAccessDescisions + */ + public function testSetAccessDecision(AccessDecision $accessDecision, string $expected) + { + $exception = new AccessDeniedException(); + $exception->setAccessDecision($accessDecision); + + $this->assertSame($expected, $exception->getMessage()); + } + + public function getAccessDescisions(): \Generator + { + yield [ + new AccessDecision(VoterInterface::ACCESS_DENIED, [ + new Vote(VoterInterface::ACCESS_DENIED, 'foo'), + new Vote(VoterInterface::ACCESS_DENIED, 'bar'), + new Vote(VoterInterface::ACCESS_DENIED, 'baz'), + ]), + 'Access Denied.'.PHP_EOL.'Decision messages are "foo" and "bar" and "baz"', + ]; + + yield [ + new AccessDecision(VoterInterface::ACCESS_DENIED,), + 'Access Denied.', + ]; + + yield [ + new AccessDecision(VoterInterface::ACCESS_DENIED,[ + new Vote(VoterInterface::ACCESS_ABSTAIN,'foo'), + new Vote(VoterInterface::ACCESS_DENIED,'bar'), + new Vote(VoterInterface::ACCESS_ABSTAIN,'baz'), + ]), + 'Access Denied.'.PHP_EOL.'Decision message is "bar"', + ]; + + yield [ + new AccessDecision(VoterInterface::ACCESS_GRANTED,[ + new Vote(VoterInterface::ACCESS_DENIED,'foo'), + ]), + 'Access Denied.'.PHP_EOL.'Decision message is "foo"', + ]; + + yield [ + new AccessDecision(VoterInterface::ACCESS_GRANTED,[ + new Vote(VoterInterface::ACCESS_DENIED,['foo', 'bar']), + new Vote(VoterInterface::ACCESS_DENIED,['baz', 'qux']), + ]), + 'Access Denied.'.PHP_EOL.'Decision messages are "foo, bar" and "baz, qux"', + ]; + } +} \ No newline at end of file diff --git a/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php b/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php index c511cf04e2398..e7b150a219fd9 100644 --- a/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php +++ b/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php @@ -18,7 +18,9 @@ use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\Exception\RuntimeException; use Symfony\Component\Security\Http\Attribute\IsGranted; @@ -59,7 +61,13 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo } } - if (!$this->authChecker->isGranted($attribute->attribute, $subject)) { + if (method_exists($this->authChecker, 'getDecision')) { + $decision = $this->authChecker->getDecision($attribute->attribute, $subject); + } else { + $decision = new AccessDecision($this->authChecker->isGranted($attribute->attribute, $subject) ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED); + } + + if (!$decision->isGranted()) { $message = $attribute->message ?: \sprintf('Access Denied by #[IsGranted(%s)] on controller', $this->getIsGrantedString($attribute)); if ($statusCode = $attribute->statusCode) { @@ -69,6 +77,7 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo $accessDeniedException = new AccessDeniedException($message, code: $attribute->exceptionCode ?? 403); $accessDeniedException->setAttributes($attribute->attribute); $accessDeniedException->setSubject($subject); + $accessDeniedException->setAccessDecision($decision); throw $accessDeniedException; } diff --git a/src/Symfony/Component/Security/Http/Firewall/AccessListener.php b/src/Symfony/Component/Security/Http/Firewall/AccessListener.php index f04de5a9fcd50..cef1edbc1afa3 100644 --- a/src/Symfony/Component/Security/Http/Firewall/AccessListener.php +++ b/src/Symfony/Component/Security/Http/Firewall/AccessListener.php @@ -15,8 +15,10 @@ use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\Security\Core\Authentication\Token\NullToken; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter; +use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Http\AccessMapInterface; use Symfony\Component\Security\Http\Event\LazyResponseEvent; @@ -73,16 +75,26 @@ public function authenticate(RequestEvent $event): void $token = $this->tokenStorage->getToken() ?? new NullToken(); - if (!$this->accessDecisionManager->decide($token, $attributes, $request, true)) { - throw $this->createAccessDeniedException($request, $attributes); + if (method_exists($this->accessDecisionManager, 'getDecision')) { + $decision = $this->accessDecisionManager->getDecision($token, $attributes, $request, true); + } else { + $decision = new AccessDecision( + $this->accessDecisionManager->decide($token, $attributes, $request, true) + ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED + ); + } + + if ($decision->isDenied()) { + throw $this->createAccessDeniedException($request, $attributes, $decision); } } - private function createAccessDeniedException(Request $request, array $attributes): AccessDeniedException + private function createAccessDeniedException(Request $request, array $attributes, AccessDecision $accessDecision): AccessDeniedException { $exception = new AccessDeniedException(); $exception->setAttributes($attributes); $exception->setSubject($request); + $exception->setAccessDecision($accessDecision); return $exception; } diff --git a/src/Symfony/Component/Security/Http/Firewall/SwitchUserListener.php b/src/Symfony/Component/Security/Http/Firewall/SwitchUserListener.php index ffdad52eef207..1494e7843e9c3 100644 --- a/src/Symfony/Component/Security/Http/Firewall/SwitchUserListener.php +++ b/src/Symfony/Component/Security/Http/Firewall/SwitchUserListener.php @@ -19,7 +19,9 @@ 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\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; +use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException; use Symfony\Component\Security\Core\Exception\AuthenticationException; @@ -154,10 +156,19 @@ private function attemptSwitchUser(Request $request, string $username): ?TokenIn throw $e; } - if (false === $this->accessDecisionManager->decide($token, [$this->role], $user)) { + if (method_exists($this->accessDecisionManager, 'getDecision')) { + $decision = $this->accessDecisionManager->getDecision($token, [$this->role], $user); + } else { + $decision = new AccessDecision( + $this->accessDecisionManager->decide($token, [$this->role], $user) + ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED + ); + } + + if ($decision->isDenied()) { $exception = new AccessDeniedException(); $exception->setAttributes($this->role); - + $exception->setAccessDecision($decision); throw $exception; } diff --git a/src/Symfony/Component/Security/Http/Tests/Firewall/AccessListenerTest.php b/src/Symfony/Component/Security/Http/Tests/Firewall/AccessListenerTest.php index 5decf414251f9..26f1ddf8c5bf2 100644 --- a/src/Symfony/Component/Security/Http/Tests/Firewall/AccessListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Firewall/AccessListenerTest.php @@ -20,8 +20,10 @@ use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter; +use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\Component\Security\Http\AccessMapInterface; @@ -30,7 +32,10 @@ class AccessListenerTest extends TestCase { - public function testHandleWhenTheAccessDecisionManagerDecidesToRefuseAccess() + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testHandleWhenTheAccessDecisionManagerDecidesToRefuseAccess(string $decideFunction, bool $useVoteObject) { $request = new Request(); @@ -51,12 +56,12 @@ public function testHandleWhenTheAccessDecisionManagerDecidesToRefuseAccess() ->willReturn($token) ; - $accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class); + $accessDecisionManager = $this->getAccessManager($useVoteObject); $accessDecisionManager ->expects($this->once()) - ->method('decide') + ->method($decideFunction) ->with($this->equalTo($token), $this->equalTo(['foo' => 'bar']), $this->equalTo($request)) - ->willReturn(false) + ->willReturn($useVoteObject ? new AccessDecision(VoterInterface::ACCESS_DENIED) : false) ; $listener = new AccessListener( @@ -126,7 +131,10 @@ public function testHandleWhenAccessMapReturnsEmptyAttributes() $listener(new LazyResponseEvent($event)); } - public function testHandleWhenTheSecurityTokenStorageHasNoToken() + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testHandleWhenTheSecurityTokenStorageHasNoToken(string $decideFunction, bool $useVoteObject) { $tokenStorage = new TokenStorage(); $request = new Request(); @@ -138,11 +146,11 @@ public function testHandleWhenTheSecurityTokenStorageHasNoToken() ->willReturn([['foo' => 'bar'], null]) ; - $accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class); + $accessDecisionManager = $this->getAccessManager($useVoteObject); $accessDecisionManager->expects($this->once()) - ->method('decide') + ->method($decideFunction) ->with($this->isInstanceOf(NullToken::class)) - ->willReturn(false); + ->willReturn($useVoteObject ? new AccessDecision(VoterInterface::ACCESS_DENIED) : false); $listener = new AccessListener( $tokenStorage, @@ -156,7 +164,10 @@ public function testHandleWhenTheSecurityTokenStorageHasNoToken() $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); } - public function testHandleWhenPublicAccessIsAllowed() + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testHandleWhenPublicAccessIsAllowed(string $decideFunction, bool $useVoteObject) { $tokenStorage = new TokenStorage(); $request = new Request(); @@ -168,11 +179,11 @@ public function testHandleWhenPublicAccessIsAllowed() ->willReturn([[AuthenticatedVoter::PUBLIC_ACCESS], null]) ; - $accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class); + $accessDecisionManager = $this->getAccessManager($useVoteObject); $accessDecisionManager->expects($this->once()) - ->method('decide') + ->method($decideFunction) ->with($this->isInstanceOf(NullToken::class), [AuthenticatedVoter::PUBLIC_ACCESS]) - ->willReturn(true); + ->willReturn($useVoteObject ? new AccessDecision(VoterInterface::ACCESS_GRANTED) : true); $listener = new AccessListener( $tokenStorage, @@ -184,7 +195,10 @@ public function testHandleWhenPublicAccessIsAllowed() $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); } - public function testHandleWhenPublicAccessWhileAuthenticated() + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testHandleWhenPublicAccessWhileAuthenticated(string $decideFunction, bool $useVoteObject) { $token = new UsernamePasswordToken(new InMemoryUser('Wouter', null, ['ROLE_USER']), 'main', ['ROLE_USER']); $tokenStorage = new TokenStorage(); @@ -198,11 +212,11 @@ public function testHandleWhenPublicAccessWhileAuthenticated() ->willReturn([[AuthenticatedVoter::PUBLIC_ACCESS], null]) ; - $accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class); + $accessDecisionManager = $this->getAccessManager($useVoteObject); $accessDecisionManager->expects($this->once()) - ->method('decide') + ->method($decideFunction) ->with($this->equalTo($token), [AuthenticatedVoter::PUBLIC_ACCESS]) - ->willReturn(true); + ->willReturn($useVoteObject ? new AccessDecision(VoterInterface::ACCESS_GRANTED) : true); $listener = new AccessListener( $tokenStorage, @@ -214,7 +228,10 @@ public function testHandleWhenPublicAccessWhileAuthenticated() $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); } - public function testHandleMWithultipleAttributesShouldBeHandledAsAnd() + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testHandleMWithultipleAttributesShouldBeHandledAsAnd(string $decideFunction, bool $useVoteObject) { $request = new Request(); @@ -231,12 +248,12 @@ public function testHandleMWithultipleAttributesShouldBeHandledAsAnd() $tokenStorage = new TokenStorage(); $tokenStorage->setToken($authenticatedToken); - $accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class); + $accessDecisionManager = $this->getAccessManager($useVoteObject); $accessDecisionManager ->expects($this->once()) - ->method('decide') + ->method($decideFunction) ->with($this->equalTo($authenticatedToken), $this->equalTo(['foo' => 'bar', 'bar' => 'baz']), $this->equalTo($request), true) - ->willReturn(true) + ->willReturn($useVoteObject ? new AccessDecision(VoterInterface::ACCESS_GRANTED) : true) ; $listener = new AccessListener( @@ -278,4 +295,28 @@ public function testConstructWithTrueExceptionOnNoToken() new AccessListener($tokenStorage, $this->createMock(AccessDecisionManagerInterface::class), $accessMap, true); } + + public function provideDataWithAndWithoutVoteObject() + { + yield [ + 'decideFunction' => 'decide', + 'useVoteObject' => false, + ]; + + yield [ + 'decideFunction' => 'getDecision', + 'useVoteObject' => true, + ]; + } + + public function getAccessManager(bool $withObject) + { + return $withObject ? + $this + ->getMockBuilder(AccessDecisionManagerInterface::class) + ->onlyMethods(['decide']) + ->addMethods(['getDecision']) + ->getMock() : + $this->createMock(AccessDecisionManagerInterface::class); + } } diff --git a/src/Symfony/Component/Security/Http/Tests/Firewall/SwitchUserListenerTest.php b/src/Symfony/Component/Security/Http/Tests/Firewall/SwitchUserListenerTest.php index 114d0db979e46..23b816cf04a53 100644 --- a/src/Symfony/Component/Security/Http/Tests/Firewall/SwitchUserListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Firewall/SwitchUserListenerTest.php @@ -21,7 +21,9 @@ use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; +use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException; use Symfony\Component\Security\Core\User\InMemoryUser; @@ -137,7 +139,10 @@ public function testExitUserDispatchesEventWithRefreshedUser() $listener($this->event); } - public function testSwitchUserIsDisallowed() + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testSwitchUserIsDisallowed($accessDecisionManager, string $decideFunction, bool $returnAsObject) { $token = new UsernamePasswordToken(new InMemoryUser('username', '', ['ROLE_FOO']), 'key', ['ROLE_FOO']); $user = new InMemoryUser('username', 'password', []); @@ -145,49 +150,55 @@ public function testSwitchUserIsDisallowed() $this->tokenStorage->setToken($token); $this->request->query->set('_switch_user', 'kuba'); - $this->accessDecisionManager->expects($this->once()) - ->method('decide')->with($token, ['ROLE_ALLOWED_TO_SWITCH']) - ->willReturn(false); + $accessDecisionManager->expects($this->once()) + ->method($decideFunction)->with($token, ['ROLE_ALLOWED_TO_SWITCH']) + ->willReturn($returnAsObject ? new AccessDecision(VoterInterface::ACCESS_DENIED) : false); - $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager); + $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $accessDecisionManager); $this->expectException(AccessDeniedException::class); $listener($this->event); } - public function testSwitchUserTurnsAuthenticationExceptionTo403() + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testSwitchUserTurnsAuthenticationExceptionTo403($accessDecisionManager, string $decideFunction, bool $returnAsObject) { $token = new UsernamePasswordToken(new InMemoryUser('username', '', ['ROLE_ALLOWED_TO_SWITCH']), 'key', ['ROLE_ALLOWED_TO_SWITCH']); $this->tokenStorage->setToken($token); $this->request->query->set('_switch_user', 'not-existing'); - $this->accessDecisionManager->expects($this->never()) - ->method('decide'); + $accessDecisionManager->expects($this->never()) + ->method($decideFunction); - $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager); + $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $accessDecisionManager); $this->expectException(AccessDeniedException::class); $listener($this->event); } - public function testSwitchUser() + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testSwitchUser($accessDecisionManager, string $decideFunction, bool $returnAsObject) { $token = new UsernamePasswordToken(new InMemoryUser('username', '', ['ROLE_FOO']), 'key', ['ROLE_FOO']); $this->tokenStorage->setToken($token); $this->request->query->set('_switch_user', 'kuba'); - $this->accessDecisionManager->expects($this->once()) - ->method('decide')->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier())) - ->willReturn(true); + $accessDecisionManager->expects($this->once()) + ->method($decideFunction)->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier())) + ->willReturn($returnAsObject ? new AccessDecision(VoterInterface::ACCESS_GRANTED) : true); $this->userChecker->expects($this->once()) ->method('checkPostAuth')->with($this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier()), $token); - $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager); + $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $accessDecisionManager); $listener($this->event); $this->assertSame([], $this->request->query->all()); @@ -195,7 +206,10 @@ public function testSwitchUser() $this->assertInstanceOf(UsernamePasswordToken::class, $this->tokenStorage->getToken()); } - public function testSwitchUserAlreadySwitched() + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testSwitchUserAlreadySwitched($accessDecisionManager, string $decideFunction, bool $returnAsObject) { $originalToken = new UsernamePasswordToken(new InMemoryUser('original', null, ['ROLE_FOO']), 'key', ['ROLE_FOO']); $alreadySwitchedToken = new SwitchUserToken(new InMemoryUser('switched_1', null, ['ROLE_BAR']), 'key', ['ROLE_BAR'], $originalToken); @@ -207,16 +221,16 @@ public function testSwitchUserAlreadySwitched() $targetsUser = $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier()); $this->accessDecisionManager->expects($this->once()) - ->method('decide')->with(self::callback(function (TokenInterface $token) use ($originalToken, $tokenStorage) { + ->method($decideFunction)->with(self::callback(function (TokenInterface $token) use ($originalToken, $tokenStorage) { // the token storage should also contain the original token for voters depending on it return $token === $originalToken && $tokenStorage->getToken() === $originalToken; }), ['ROLE_ALLOWED_TO_SWITCH'], $targetsUser) - ->willReturn(true); + ->willReturn($returnAsObject ? new AccessDecision(VoterInterface::ACCESS_GRANTED) : true); $this->userChecker->expects($this->once()) ->method('checkPostAuth')->with($targetsUser); - $listener = new SwitchUserListener($tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager, null, '_switch_user', 'ROLE_ALLOWED_TO_SWITCH', null, false); + $listener = new SwitchUserListener($tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $accessDecisionManager, null, '_switch_user', 'ROLE_ALLOWED_TO_SWITCH', null, false); $listener($this->event); $this->assertSame([], $this->request->query->all()); @@ -226,7 +240,10 @@ public function testSwitchUserAlreadySwitched() $this->assertSame($originalToken, $tokenStorage->getToken()->getOriginalToken()); } - public function testSwitchUserWorksWithFalsyUsernames() + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testSwitchUserWorksWithFalsyUsernames($accessDecisionManager, string $decideFunction, bool $returnAsObject) { $token = new UsernamePasswordToken(new InMemoryUser('kuba', '', ['ROLE_FOO']), 'key', ['ROLE_FOO']); @@ -235,14 +252,14 @@ public function testSwitchUserWorksWithFalsyUsernames() $this->userProvider->createUser($user = new InMemoryUser('0', null)); - $this->accessDecisionManager->expects($this->once()) - ->method('decide')->with($token, ['ROLE_ALLOWED_TO_SWITCH']) - ->willReturn(true); + $accessDecisionManager->expects($this->once()) + ->method($decideFunction)->with($token, ['ROLE_ALLOWED_TO_SWITCH']) + ->willReturn($returnAsObject ? new AccessDecision(VoterInterface::ACCESS_GRANTED) : true); $this->userChecker->expects($this->once()) ->method('checkPostAuth')->with($this->callback(fn ($argUser) => $user->isEqualTo($argUser))); - $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager); + $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $accessDecisionManager); $listener($this->event); $this->assertSame([], $this->request->query->all()); @@ -250,7 +267,10 @@ public function testSwitchUserWorksWithFalsyUsernames() $this->assertInstanceOf(UsernamePasswordToken::class, $this->tokenStorage->getToken()); } - public function testSwitchUserKeepsOtherQueryStringParameters() + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testSwitchUserKeepsOtherQueryStringParameters($accessDecisionManager, string $decideFunction, bool $returnAsObject) { $token = new UsernamePasswordToken(new InMemoryUser('username', '', ['ROLE_FOO']), 'key', ['ROLE_FOO']); @@ -262,21 +282,24 @@ public function testSwitchUserKeepsOtherQueryStringParameters() ]); $targetsUser = $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier()); - $this->accessDecisionManager->expects($this->once()) - ->method('decide')->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $targetsUser) - ->willReturn(true); + $accessDecisionManager->expects($this->once()) + ->method($decideFunction)->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $targetsUser) + ->willReturn($returnAsObject ? new AccessDecision(VoterInterface::ACCESS_GRANTED) : true); $this->userChecker->expects($this->once()) ->method('checkPostAuth')->with($targetsUser); - $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager); + $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $accessDecisionManager); $listener($this->event); $this->assertSame('page=3§ion=2', $this->request->server->get('QUERY_STRING')); $this->assertInstanceOf(UsernamePasswordToken::class, $this->tokenStorage->getToken()); } - public function testSwitchUserWithReplacedToken() + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testSwitchUserWithReplacedToken($accessDecisionManager, string $decideFunction, bool $returnAsObject) { $user = new InMemoryUser('username', 'password', []); $token = new UsernamePasswordToken($user, 'provider123', ['ROLE_FOO']); @@ -287,9 +310,9 @@ public function testSwitchUserWithReplacedToken() $this->tokenStorage->setToken($token); $this->request->query->set('_switch_user', 'kuba'); - $this->accessDecisionManager->expects($this->any()) - ->method('decide')->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier())) - ->willReturn(true); + $accessDecisionManager->expects($this->any()) + ->method($decideFunction)->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier())) + ->willReturn($returnAsObject ? new AccessDecision(VoterInterface::ACCESS_GRANTED) : true); $dispatcher = $this->createMock(EventDispatcherInterface::class); $dispatcher @@ -307,7 +330,7 @@ public function testSwitchUserWithReplacedToken() SecurityEvents::SWITCH_USER ); - $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager, null, '_switch_user', 'ROLE_ALLOWED_TO_SWITCH', $dispatcher); + $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $accessDecisionManager, null, '_switch_user', 'ROLE_ALLOWED_TO_SWITCH', $dispatcher); $listener($this->event); $this->assertSame($replacedToken, $this->tokenStorage->getToken()); @@ -324,7 +347,10 @@ public function testSwitchUserThrowsAuthenticationExceptionIfNoCurrentToken() $listener($this->event); } - public function testSwitchUserStateless() + /** + * @dataProvider provideDataWithAndWithoutVoteObject + */ + public function testSwitchUserStateless($accessDecisionManager, string $decideFunction, bool $returnAsObject) { $token = new UsernamePasswordToken(new InMemoryUser('username', '', ['ROLE_FOO']), 'key', ['ROLE_FOO']); @@ -332,14 +358,15 @@ public function testSwitchUserStateless() $this->request->query->set('_switch_user', 'kuba'); $targetsUser = $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier()); - $this->accessDecisionManager->expects($this->once()) - ->method('decide')->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $targetsUser) - ->willReturn(true); + + $accessDecisionManager->expects($this->once()) + ->method($decideFunction)->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $targetsUser) + ->willReturn($returnAsObject ? new AccessDecision(VoterInterface::ACCESS_GRANTED) : true); $this->userChecker->expects($this->once()) ->method('checkPostAuth')->with($targetsUser); - $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager, null, '_switch_user', 'ROLE_ALLOWED_TO_SWITCH', null, true); + $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $accessDecisionManager, null, '_switch_user', 'ROLE_ALLOWED_TO_SWITCH', null, true); $listener($this->event); $this->assertInstanceOf(UsernamePasswordToken::class, $this->tokenStorage->getToken()); @@ -373,4 +400,23 @@ public function testSwitchUserRefreshesOriginalToken() $listener = new SwitchUserListener($this->tokenStorage, $userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager, null, '_switch_user', 'ROLE_ALLOWED_TO_SWITCH', $dispatcher); $listener($this->event); } + + public function provideDataWithAndWithoutVoteObject() + { + yield [ + 'accessDecisionManager' => $this->createMock(AccessDecisionManagerInterface::class), + 'decideFunction' => 'decide', + 'returnAsObject' => false, + ]; + + yield [ + 'accessDecisionManager' => $this + ->getMockBuilder(AccessDecisionManagerInterface::class) + ->onlyMethods(['decide']) + ->addMethods(['getDecision']) + ->getMock(), + 'decideFunction' => 'getDecision', + 'returnAsObject' => true, + ]; + } } From 1caecb5806974c00874f2003b3327bf921ce86a9 Mon Sep 17 00:00:00 2001 From: eltharin Date: Tue, 27 Aug 2024 21:31:59 +0200 Subject: [PATCH 02/12] review corrections --- .../Security/Core/Authorization/AccessDecision.php | 9 ++++++--- .../Security/Core/Authorization/Voter/Vote.php | 11 ++++++++--- .../Core/Authorization/Voter/VoteInterface.php | 4 +--- src/Symfony/Component/Security/Core/CHANGELOG.md | 2 +- .../Security/Core/Exception/AccessDeniedException.php | 3 --- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php b/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php index c0065a2988b2d..5b7d952ad625f 100644 --- a/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php +++ b/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php @@ -24,11 +24,14 @@ final class AccessDecision { /** - * @param int $access One of the VoterInterface::ACCESS_* constants + * @param int $access One of the VoterInterface constants (ACCESS_GRANTED, ACCESS_ABSTAIN, ACCESS_DENIED) * @param Vote[] $votes */ - public function __construct(private readonly int $access, private readonly array $votes = [], private readonly string $message = '') - { + public function __construct( + private readonly int $access, + private readonly array $votes = [], + private readonly string $message = '', + ) { } public function getAccess(): int diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php b/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php index c657fab52f680..61906a45e79f4 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php @@ -29,10 +29,15 @@ class Vote implements VoteInterface private array $messages; /** - * @param int $access One of the VoterInterface::ACCESS_* constants if scoring is false + * @param int $access One of the VoterInterface constants (ACCESS_GRANTED, ACCESS_ABSTAIN, ACCESS_DENIED) + * or an integer when scoring is false */ - public function __construct(private int $access, string|array $messages = [], private array $context = [], private $scoring = false) - { + public function __construct( + private int $access, + string|array $messages = [], + private array $context = [], + private $scoring = false, + ) { if (!$scoring && !\in_array($access, [VoterInterface::ACCESS_GRANTED, VoterInterface::ACCESS_ABSTAIN, VoterInterface::ACCESS_DENIED], true)) { throw new \LogicException(\sprintf('"$access" must return one of "%s" constants ("ACCESS_GRANTED", "ACCESS_DENIED" or "ACCESS_ABSTAIN") when "$scoring" is false, "%s" returned.', VoterInterface::class, $access)); } diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/VoteInterface.php b/src/Symfony/Component/Security/Core/Authorization/Voter/VoteInterface.php index 6b4eeb8c0240c..41ace4200e675 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/VoteInterface.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/VoteInterface.php @@ -12,14 +12,12 @@ namespace Symfony\Component\Security\Core\Authorization\Voter; /** - * A VoteInterface Object contain information about vote, access/score, messages. + * A VoteInterface object contain information about vote, access/score, messages. * * @author Roman JOLY */ interface VoteInterface { - public function __construct(int $access, string|array $messages = [], array $context = []); - public function __debugInfo(): array; public function getAccess(): int; diff --git a/src/Symfony/Component/Security/Core/CHANGELOG.md b/src/Symfony/Component/Security/Core/CHANGELOG.md index d45119ce4c55b..37d07ba974f64 100644 --- a/src/Symfony/Component/Security/Core/CHANGELOG.md +++ b/src/Symfony/Component/Security/Core/CHANGELOG.md @@ -17,7 +17,7 @@ CHANGELOG * Add `$token` argument to `UserCheckerInterface::checkPostAuth()` * Deprecate argument `$secret` of `RememberMeToken` * Deprecate returning an empty string in `UserInterface::getUserIdentifier()` - * Add the ability for voter to return decision reason and a score by passing a Vote object + * Add the ability for voter to return decision reason and a score by passing a `Vote` object 7.0 --- diff --git a/src/Symfony/Component/Security/Core/Exception/AccessDeniedException.php b/src/Symfony/Component/Security/Core/Exception/AccessDeniedException.php index 1018e16e99ed2..1e33836a2ddc0 100644 --- a/src/Symfony/Component/Security/Core/Exception/AccessDeniedException.php +++ b/src/Symfony/Component/Security/Core/Exception/AccessDeniedException.php @@ -69,9 +69,6 @@ public function setAccessDecision(AccessDecision $accessDecision): void } } - /** - * Gets the access decision. - */ public function getAccessDecision(): ?AccessDecision { return $this->accessDecision; From 0ca4e01283c3a06885f4d5ea2c4cf0bced6211fa Mon Sep 17 00:00:00 2001 From: eltharin Date: Tue, 27 Aug 2024 23:41:25 +0200 Subject: [PATCH 03/12] psalm correction fabpot use interface --- .../SecurityDataCollectorTest.php | 4 +++ .../Core/Authorization/AccessDecision.php | 25 +++++++++---------- .../Strategy/AffirmativeStrategy.php | 3 ++- .../Strategy/ConsensusStrategy.php | 3 ++- .../Strategy/PriorityStrategy.php | 3 ++- .../Strategy/ScoringStrategy.php | 3 ++- .../Strategy/UnanimousStrategy.php | 3 ++- .../TraceableAccessDecisionManager.php | 2 +- .../Authorization/Voter/VoterInterface.php | 2 +- .../Security/Core/Event/VoteEvent.php | 8 +++--- .../Test/AccessDecisionStrategyTestCase.php | 6 +++++ .../AccessDecisionManagerTest.php | 5 ++++ .../Strategy/ScoringStrategyTest.php | 6 +++++ .../Core/Tests/Fixtures/DummyVoter.php | 5 ++++ 14 files changed, 54 insertions(+), 24 deletions(-) diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DataCollector/SecurityDataCollectorTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DataCollector/SecurityDataCollectorTest.php index 21161d281eb92..bf23c0032f890 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DataCollector/SecurityDataCollectorTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DataCollector/SecurityDataCollectorTest.php @@ -464,4 +464,8 @@ final class DummyVoter implements VoterInterface public function vote(TokenInterface $token, mixed $subject, array $attributes): int { } + + public function getVote(TokenInterface $token, mixed $subject, array $attributes): VoterInterface + { + } } diff --git a/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php b/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php index 5b7d952ad625f..ba4b874675612 100644 --- a/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php +++ b/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php @@ -11,8 +11,7 @@ namespace Symfony\Component\Security\Core\Authorization; -use Symfony\Component\Security\Core\Authorization\Voter\Vote; -use Symfony\Component\Security\Core\Authorization\Voter\Voter; +use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; /** @@ -24,8 +23,8 @@ final class AccessDecision { /** - * @param int $access One of the VoterInterface constants (ACCESS_GRANTED, ACCESS_ABSTAIN, ACCESS_DENIED) - * @param Vote[] $votes + * @param int $access One of the VoterInterface constants (ACCESS_GRANTED, ACCESS_ABSTAIN, ACCESS_DENIED) + * @param VoteInterface[] $votes */ public function __construct( private readonly int $access, @@ -60,7 +59,7 @@ public function getMessage(): string } /** - * @return Vote[] + * @return VoteInterface[] */ public function getVotes(): array { @@ -68,34 +67,34 @@ public function getVotes(): array } /** - * @return Vote[] + * @return VoteInterface[] */ public function getGrantedVotes(): array { - return $this->getVotesByAccess(Voter::ACCESS_GRANTED); + return $this->getVotesByAccess(VoterInterface::ACCESS_GRANTED); } /** - * @return Vote[] + * @return VoteInterface[] */ public function getAbstainedVotes(): array { - return $this->getVotesByAccess(Voter::ACCESS_ABSTAIN); + return $this->getVotesByAccess(VoterInterface::ACCESS_ABSTAIN); } /** - * @return Vote[] + * @return VoteInterface[] */ public function getDeniedVotes(): array { - return $this->getVotesByAccess(Voter::ACCESS_DENIED); + return $this->getVotesByAccess(VoterInterface::ACCESS_DENIED); } /** - * @return Vote[] + * @return VoteInterface[] */ private function getVotesByAccess(int $access): array { - return array_filter($this->votes, static fn (Vote $vote): bool => $vote->getAccess() === $access); + return array_filter($this->votes, static fn (VoteInterface $vote): bool => $vote->getAccess() === $access); } } diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/AffirmativeStrategy.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/AffirmativeStrategy.php index e15740de0c523..0c4a7b4007099 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Strategy/AffirmativeStrategy.php +++ b/src/Symfony/Component/Security/Core/Authorization/Strategy/AffirmativeStrategy.php @@ -13,6 +13,7 @@ use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\Voter\Vote; +use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; /** @@ -41,7 +42,7 @@ public function getDecision(\Traversable $votes): AccessDecision $currentVotes = []; $deny = 0; - /** @var Vote $vote */ + /** @var VoteInterface $vote */ foreach ($votes as $vote) { $currentVotes[] = $vote; diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/ConsensusStrategy.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/ConsensusStrategy.php index 3a77b28529b1e..8779ef82e08aa 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Strategy/ConsensusStrategy.php +++ b/src/Symfony/Component/Security/Core/Authorization/Strategy/ConsensusStrategy.php @@ -13,6 +13,7 @@ use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\Voter\Vote; +use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; /** @@ -51,7 +52,7 @@ public function getDecision(\Traversable $votes): AccessDecision $grant = 0; $deny = 0; - /** @var Vote $vote */ + /** @var VoteInterface $vote */ foreach ($votes as $vote) { $currentVotes[] = $vote; if ($vote->isGranted()) { diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/PriorityStrategy.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/PriorityStrategy.php index 8f0426a56d5cb..67896f3c4897b 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Strategy/PriorityStrategy.php +++ b/src/Symfony/Component/Security/Core/Authorization/Strategy/PriorityStrategy.php @@ -13,6 +13,7 @@ use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\Voter\Vote; +use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; /** @@ -41,7 +42,7 @@ public function getDecision(\Traversable $votes): AccessDecision { $currentVotes = []; - /** @var Vote $vote */ + /** @var VoteInterface $vote */ foreach ($votes as $vote) { $currentVotes[] = $vote; if ($vote->isGranted()) { diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/ScoringStrategy.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/ScoringStrategy.php index b57ec73162e48..a921f2b943a79 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Strategy/ScoringStrategy.php +++ b/src/Symfony/Component/Security/Core/Authorization/Strategy/ScoringStrategy.php @@ -13,6 +13,7 @@ use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\Voter\Vote; +use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; /** @@ -40,7 +41,7 @@ public function getDecision(\Traversable $votes): AccessDecision $currentVotes = []; $score = 0; - /** @var Vote $vote */ + /** @var VoteInterface $vote */ foreach ($votes as $vote) { $currentVotes[] = $vote; $score += $vote->getAccess(); diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/UnanimousStrategy.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/UnanimousStrategy.php index 6b3bba532c50e..0208dc991ae39 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Strategy/UnanimousStrategy.php +++ b/src/Symfony/Component/Security/Core/Authorization/Strategy/UnanimousStrategy.php @@ -13,6 +13,7 @@ use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\Voter\Vote; +use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; /** @@ -41,7 +42,7 @@ public function getDecision(\Traversable $votes): AccessDecision $currentVotes = []; $grant = 0; - /** @var Vote $vote */ + /** @var VoteInterface $vote */ foreach ($votes as $vote) { $currentVotes[] = $vote; diff --git a/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php b/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php index ba920274b5095..777126e77da7c 100644 --- a/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php +++ b/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php @@ -93,7 +93,7 @@ public function decide(TokenInterface $token, array $attributes, mixed $object = */ public function addVoterVote(VoterInterface $voter, array $attributes, VoteInterface|int $vote): void { - if (!$vote instanceof Vote) { + if (!$vote instanceof VoteInterface) { $vote = new Vote($vote); } diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php b/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php index 19e5f304eaaca..2f75aa0b4609a 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php @@ -18,7 +18,7 @@ * * @author Fabien Potencier * - * method Vote getVote(TokenInterface $token, mixed $subject, array $attributes) + * @method VoteInterface getVote(TokenInterface $token, mixed $subject, array $attributes) */ interface VoterInterface { diff --git a/src/Symfony/Component/Security/Core/Event/VoteEvent.php b/src/Symfony/Component/Security/Core/Event/VoteEvent.php index 5b2c0ae0b01e8..69a2cc0c805ea 100644 --- a/src/Symfony/Component/Security/Core/Event/VoteEvent.php +++ b/src/Symfony/Component/Security/Core/Event/VoteEvent.php @@ -25,15 +25,15 @@ */ final class VoteEvent extends Event { + private VoteInterface $vote; + public function __construct( private VoterInterface $voter, private mixed $subject, private array $attributes, - private Vote|int $vote, + VoteInterface|int $vote, ) { - if (!$vote instanceof Vote) { - $this->vote = new Vote($vote); - } + $this->vote = $vote instanceof VoteInterface ? $vote : new Vote($vote); } public function getVoter(): VoterInterface diff --git a/src/Symfony/Component/Security/Core/Test/AccessDecisionStrategyTestCase.php b/src/Symfony/Component/Security/Core/Test/AccessDecisionStrategyTestCase.php index 1ee84b080c266..35554f1c16b64 100644 --- a/src/Symfony/Component/Security/Core/Test/AccessDecisionStrategyTestCase.php +++ b/src/Symfony/Component/Security/Core/Test/AccessDecisionStrategyTestCase.php @@ -19,6 +19,7 @@ use Symfony\Component\Security\Core\Authorization\Strategy\AccessDecisionStrategyInterface; use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; +use Symfony\Component\Security\Core\Exception\LogicException; /** * Abstract test case for access decision strategies. @@ -93,6 +94,11 @@ public function vote(TokenInterface $token, $subject, array $attributes): int { return $this->vote; } + + public function __call($function, $args) + { + throw new LogicException('This function must not be acceded.'); + } }; } diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php index b2afd2a74666e..46e6bc378db19 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php @@ -354,6 +354,11 @@ public function vote(TokenInterface $token, $subject, array $attributes): int { return $this->vote; } + + public function __call($function, $args) + { + throw new LogicException('This function must not be acceded.'); + } }; } diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ScoringStrategyTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ScoringStrategyTest.php index fdd66001e9491..288a032fa7ea6 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ScoringStrategyTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ScoringStrategyTest.php @@ -11,6 +11,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; +use Symfony\Component\Security\Core\Exception\LogicException; class ScoringStrategyTest extends TestCase { @@ -100,6 +101,11 @@ public function vote(TokenInterface $token, $subject, array $attributes): int { return $this->vote; } + + public function __call($function, $args) + { + throw new LogicException('This function must not be acceded.'); + } }; } diff --git a/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyVoter.php b/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyVoter.php index 1f923423a21ed..f267c6c06be04 100644 --- a/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyVoter.php +++ b/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyVoter.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Security\Core\Tests\Fixtures; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; final class DummyVoter implements VoterInterface @@ -19,4 +20,8 @@ final class DummyVoter implements VoterInterface public function vote(TokenInterface $token, $subject, array $attributes): int { } + + public function getVote(TokenInterface $token, mixed $subject, array $attributes): VoterInterface + { + } } From 96e61934a342ca30e41757173d0dc165e35226ba Mon Sep 17 00:00:00 2001 From: eltharin Date: Thu, 29 Aug 2024 10:34:19 +0200 Subject: [PATCH 04/12] acces is int not bool --- .../Component/Security/Core/Authorization/Voter/Vote.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php b/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php index 61906a45e79f4..b22940e34b580 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php @@ -60,7 +60,7 @@ public function getAccess(): int public function isGranted(): bool { - return true === $this->access || $this->access > 0; + return $this->access > 0; } public function isAbstain(): bool @@ -70,7 +70,7 @@ public function isAbstain(): bool public function isDenied(): bool { - return false === $this->access || $this->access < 0; + return $this->access < 0; } /** From 2a903b302d3bcd64b591f2024769b046acb8e949 Mon Sep 17 00:00:00 2001 From: eltharin Date: Tue, 10 Sep 2024 10:36:02 +0200 Subject: [PATCH 05/12] changes from review --- .../Security/Core/Authorization/Strategy/ScoringStrategy.php | 2 +- .../Component/Security/Core/Authorization/Voter/Vote.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/ScoringStrategy.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/ScoringStrategy.php index a921f2b943a79..efbe2b3ff4ecf 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Strategy/ScoringStrategy.php +++ b/src/Symfony/Component/Security/Core/Authorization/Strategy/ScoringStrategy.php @@ -17,7 +17,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; /** - * Grants access if vote results greated than 0. + * Grants access if the sum of vote results is greater than 0. * * If all voters abstained from voting, the decision will be based on the * allowIfAllAbstainDecisions property value (defaults to false). diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php b/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php index b22940e34b580..6b86a8d489260 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php @@ -14,7 +14,7 @@ use Symfony\Component\Security\Core\Exception\InvalidArgumentException; /** - * A Vote is returned by a Voter and contains the access (granted, abstain or denied). + * A Vote is returned by a Voter and contains the access result (granted, abstain or denied). * It can also contain one or multiple messages explaining the vote decision. * * @author Dany Maillard From 52a7ea35a01ab64594a02e71faa8d6073c4a3fec Mon Sep 17 00:00:00 2001 From: eltharin Date: Sun, 15 Sep 2024 15:31:01 +0200 Subject: [PATCH 06/12] vote object in voteEvent --- .../Bundle/SecurityBundle/EventListener/VoteListener.php | 2 +- src/Symfony/Component/Security/Core/Event/VoteEvent.php | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php b/src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php index 54eac4384542a..2d29f7bd86728 100644 --- a/src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php +++ b/src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php @@ -31,7 +31,7 @@ public function __construct( public function onVoterVote(VoteEvent $event): void { - $this->traceableAccessDecisionManager->addVoterVote($event->getVoter(), $event->getAttributes(), $event->getVote()); + $this->traceableAccessDecisionManager->addVoterVote($event->getVoter(), $event->getAttributes(), $event->getVoteObject()); } public static function getSubscribedEvents(): array diff --git a/src/Symfony/Component/Security/Core/Event/VoteEvent.php b/src/Symfony/Component/Security/Core/Event/VoteEvent.php index 69a2cc0c805ea..abe2d8bfd6a21 100644 --- a/src/Symfony/Component/Security/Core/Event/VoteEvent.php +++ b/src/Symfony/Component/Security/Core/Event/VoteEvent.php @@ -51,7 +51,12 @@ public function getAttributes(): array return $this->attributes; } - public function getVote(): VoteInterface + public function getVote(): int + { + return $this->vote->getAccess(); + } + + public function getVoteObject(): VoteInterface { return $this->vote; } From f4c704d579effa1ca0de3580d7de318d6370011e Mon Sep 17 00:00:00 2001 From: eltharin Date: Thu, 9 Jan 2025 22:51:39 +0100 Subject: [PATCH 07/12] nicolas_grekas review --- .../Bundle/SecurityBundle/Security.php | 8 ++++---- .../Core/Authorization/AccessDecision.php | 10 +++++----- .../Authorization/AccessDecisionManager.php | 18 ++++++++---------- .../Core/Authorization/Voter/Vote.php | 6 +++--- .../Authorization/Voter/VoteInterface.php | 2 +- .../Core/Authorization/Voter/Voter.php | 1 + .../Authorization/Voter/VoterInterface.php | 5 ++++- .../Component/Security/Core/CHANGELOG.md | 2 +- .../AccessDecisionManagerTest.php | 2 +- .../AuthorizationCheckerTest.php | 1 - .../Strategy/ScoringStrategyTest.php | 19 ++++++++++++++----- 11 files changed, 42 insertions(+), 32 deletions(-) diff --git a/src/Symfony/Bundle/SecurityBundle/Security.php b/src/Symfony/Bundle/SecurityBundle/Security.php index 878c59d187f38..1821b019115c3 100644 --- a/src/Symfony/Bundle/SecurityBundle/Security.php +++ b/src/Symfony/Bundle/SecurityBundle/Security.php @@ -66,16 +66,16 @@ public function isGranted(mixed $attributes, mixed $subject = null): bool } /** - * Get the access decision against the current authentication token and optionally supplied subject. + * Gets the access decision against the current authentication token and optionally supplied subject. */ public function getDecision(mixed $attribute, mixed $subject = null): AccessDecision { $checker = $this->container->get('security.authorization_checker'); - if (method_exists($checker, 'getDecision')) { - return $checker->getDecision($attribute, $subject); + if (!method_exists($checker, 'getDecision')) { + return new AccessDecision($checker->isGranted($attribute, $subject) ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED); } - return new AccessDecision($checker->isGranted($attribute, $subject) ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED); + return $checker->getDecision($attribute, $subject); } public function getToken(): ?TokenInterface diff --git a/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php b/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php index ba4b874675612..0811d2779df7c 100644 --- a/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php +++ b/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php @@ -23,8 +23,8 @@ final class AccessDecision { /** - * @param int $access One of the VoterInterface constants (ACCESS_GRANTED, ACCESS_ABSTAIN, ACCESS_DENIED) - * @param VoteInterface[] $votes + * @param VoterInterface::ACCESS_*|int $access + * @param VoteInterface[] $votes */ public function __construct( private readonly int $access, @@ -43,7 +43,7 @@ public function isGranted(): bool return VoterInterface::ACCESS_GRANTED === $this->access; } - public function isAbstain(): bool + public function isAbstainer(): bool { return VoterInterface::ACCESS_ABSTAIN === $this->access; } @@ -77,7 +77,7 @@ public function getGrantedVotes(): array /** * @return VoteInterface[] */ - public function getAbstainedVotes(): array + public function getAbstainerVotes(): array { return $this->getVotesByAccess(VoterInterface::ACCESS_ABSTAIN); } @@ -95,6 +95,6 @@ public function getDeniedVotes(): array */ private function getVotesByAccess(int $access): array { - return array_filter($this->votes, static fn (VoteInterface $vote): bool => $vote->getAccess() === $access); + return array_filter($this->votes, static fn ($vote) => $vote->getAccess() === $access); } } diff --git a/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php b/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php index 7ec4bcb8fb461..3438c7423e317 100644 --- a/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php +++ b/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php @@ -43,20 +43,19 @@ public function __construct( public function getDecision(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false): AccessDecision { - // Special case for AccessListener, do not remove the right side of the condition before 6.0 if (\count($attributes) > 1 && !$allowMultipleAttributes) { throw new InvalidArgumentException(\sprintf('Passing more than one Security attribute to "%s()" is not supported.', __METHOD__)); } - if (method_exists($this->strategy, 'getDecision')) { - $decision = $this->strategy->getDecision( - $this->collectVotes($token, $attributes, $object) - ); - } else { + if (!method_exists($this->strategy, 'getDecision')) { $decision = new AccessDecision( $this->strategy->decide($this->collectResults($token, $attributes, $object)) ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED ); + } else { + $decision = $this->strategy->getDecision( + $this->collectVotes($token, $attributes, $object) + ); } return $decision; @@ -67,7 +66,6 @@ public function getDecision(TokenInterface $token, array $attributes, mixed $obj */ public function decide(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false): bool { - // Special case for AccessListener, do not remove the right side of the condition before 6.0 if (\count($attributes) > 1 && !$allowMultipleAttributes) { throw new InvalidArgumentException(\sprintf('Passing more than one Security attribute to "%s()" is not supported.', __METHOD__)); } @@ -83,10 +81,10 @@ public function decide(TokenInterface $token, array $attributes, mixed $object = private function collectVotes(TokenInterface $token, array $attributes, mixed $object): \Traversable { foreach ($this->getVoters($attributes, $object) as $voter) { - if (method_exists($voter, 'getVote')) { - yield $voter->getVote($token, $object, $attributes); - } else { + if (!method_exists($voter, 'getVote')) { yield new Vote($voter->vote($token, $object, $attributes)); + } else { + yield $voter->getVote($token, $object, $attributes); } } } diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php b/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php index 6b86a8d489260..098d8bc05f3e9 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php @@ -29,14 +29,14 @@ class Vote implements VoteInterface private array $messages; /** - * @param int $access One of the VoterInterface constants (ACCESS_GRANTED, ACCESS_ABSTAIN, ACCESS_DENIED) + * @param VoterInterface::ACCESS_*|int $access One of the VoterInterface constants (ACCESS_GRANTED, ACCESS_ABSTAIN, ACCESS_DENIED) * or an integer when scoring is false */ public function __construct( private int $access, string|array $messages = [], private array $context = [], - private $scoring = false, + private bool $scoring = false, ) { if (!$scoring && !\in_array($access, [VoterInterface::ACCESS_GRANTED, VoterInterface::ACCESS_ABSTAIN, VoterInterface::ACCESS_DENIED], true)) { throw new \LogicException(\sprintf('"$access" must return one of "%s" constants ("ACCESS_GRANTED", "ACCESS_DENIED" or "ACCESS_ABSTAIN") when "$scoring" is false, "%s" returned.', VoterInterface::class, $access)); @@ -63,7 +63,7 @@ public function isGranted(): bool return $this->access > 0; } - public function isAbstain(): bool + public function isAbstainer(): bool { return VoterInterface::ACCESS_ABSTAIN === $this->access; } diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/VoteInterface.php b/src/Symfony/Component/Security/Core/Authorization/Voter/VoteInterface.php index 41ace4200e675..6d4cd7125cc7b 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/VoteInterface.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/VoteInterface.php @@ -24,7 +24,7 @@ public function getAccess(): int; public function isGranted(): bool; - public function isAbstain(): bool; + public function isAbstainer(): bool; public function isDenied(): bool; diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php index 503e57df319d4..1fb5bc6fd089a 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php @@ -38,6 +38,7 @@ public function getVote(TokenInterface $token, mixed $subject, array $attributes if (str_contains($e->getMessage(), 'supports(): Argument #1')) { continue; } + throw $e; } diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php b/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php index 2f75aa0b4609a..3ebb14eeefc9b 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php @@ -32,8 +32,11 @@ interface VoterInterface * This method must return one of the following constants: * ACCESS_GRANTED, ACCESS_DENIED, or ACCESS_ABSTAIN. * - * @param mixed $subject The subject to secure + * @param TokenInterface $token + * @param mixed $subject The subject to secure * @param array $attributes An array of attributes associated with the method being invoked + * + * @return int */ public function vote(TokenInterface $token, mixed $subject, array $attributes): int; } diff --git a/src/Symfony/Component/Security/Core/CHANGELOG.md b/src/Symfony/Component/Security/Core/CHANGELOG.md index 37d07ba974f64..7abde6433e7d1 100644 --- a/src/Symfony/Component/Security/Core/CHANGELOG.md +++ b/src/Symfony/Component/Security/Core/CHANGELOG.md @@ -9,6 +9,7 @@ CHANGELOG * Add `OfflineTokenInterface` to mark tokens that do not represent the currently logged-in user * Deprecate `UserInterface::eraseCredentials()` and `TokenInterface::eraseCredentials()`, erase credentials e.g. using `__serialize()` instead + * Add the ability for voter to return decision reason and a score by passing a `Vote` object 7.2 --- @@ -17,7 +18,6 @@ CHANGELOG * Add `$token` argument to `UserCheckerInterface::checkPostAuth()` * Deprecate argument `$secret` of `RememberMeToken` * Deprecate returning an empty string in `UserInterface::getUserIdentifier()` - * Add the ability for voter to return decision reason and a score by passing a `Vote` object 7.0 --- diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php index 46e6bc378db19..0cfdd453886ce 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php @@ -75,7 +75,7 @@ public function testVoterCalls($useVoteObject, $decideFunction, $voteFunction, $ $this->getUnexpectedVoter(), ]; - if($useVoteObject) { + if ($useVoteObject) { $strategy = new class() implements AccessDecisionStrategyInterface { public function decide(\Traversable $results): bool { throw new LogicException('Method should not be called'); } // never call public function getDecision(\Traversable $votes): AccessDecision diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/AuthorizationCheckerTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/AuthorizationCheckerTest.php index a8b82684361b6..f002d203fe7c0 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/AuthorizationCheckerTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/AuthorizationCheckerTest.php @@ -115,7 +115,6 @@ public function testIsGrantedWithObjectAttribute($useVoteObject, $decideFunction $this->assertTrue($authorizationChecker->isGranted($attribute)); } - public function createAccessDecisionManagerMock(bool $useVoteObject) { return $useVoteObject ? diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ScoringStrategyTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ScoringStrategyTest.php index 288a032fa7ea6..2f269abc4b4e1 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ScoringStrategyTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ScoringStrategyTest.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Authorization\Strategy; use PHPUnit\Framework\TestCase; @@ -20,7 +29,7 @@ class ScoringStrategyTest extends TestCase * * @param VoterInterface[] $voters */ - final public function testGetDecision(AccessDecisionStrategyInterface $strategy, array $voters, AccessDecision $expected) + public function testGetDecision(AccessDecisionStrategyInterface $strategy, array $voters, AccessDecision $expected) { $token = $this->createMock(TokenInterface::class); $manager = new AccessDecisionManager($voters, $strategy); @@ -89,7 +98,7 @@ public static function provideStrategyTests(): iterable } - final protected static function getVoter(int $vote): VoterInterface + protected static function getVoter(int $vote): VoterInterface { return new class($vote) implements VoterInterface { public function __construct( @@ -109,7 +118,7 @@ public function __call($function, $args) }; } - final protected static function getVoterWithVoteObject(int $vote): VoterInterface + protected static function getVoterWithVoteObject(int $vote): VoterInterface { return new class($vote) implements VoterInterface { public function __construct( @@ -129,7 +138,7 @@ public function getVote(TokenInterface $token, mixed $subject, array $attributes }; } - final protected static function getVoterWithVoteObjectAndScoring(int $vote): VoterInterface + protected static function getVoterWithVoteObjectAndScoring(int $vote): VoterInterface { return new class($vote) implements VoterInterface { public function __construct( @@ -149,7 +158,7 @@ public function getVote(TokenInterface $token, mixed $subject, array $attributes }; } - final protected static function getAccessDecision(bool $decision, array $votes, string $message): AccessDecision + protected static function getAccessDecision(bool $decision, array $votes, string $message): AccessDecision { return new AccessDecision($decision ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, array_map(fn ($vote) => new Vote($vote[0], scoring: $vote[1]), $votes), From 7e49fca543bf8cdff35287942b3c75cc9ef16d58 Mon Sep 17 00:00:00 2001 From: eltharin Date: Fri, 10 Jan 2025 10:03:57 +0100 Subject: [PATCH 08/12] remove BC break --- .../TraceableAccessDecisionManager.php | 4 - .../TraceableAccessDecisionManagerTest.php | 128 +++++++++++++----- 2 files changed, 91 insertions(+), 41 deletions(-) diff --git a/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php b/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php index 777126e77da7c..6e6195e072ebf 100644 --- a/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php +++ b/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php @@ -93,10 +93,6 @@ public function decide(TokenInterface $token, array $attributes, mixed $object = */ public function addVoterVote(VoterInterface $voter, array $attributes, VoteInterface|int $vote): void { - if (!$vote instanceof VoteInterface) { - $vote = new Vote($vote); - } - $currentLogIndex = \count($this->currentLog) - 1; $this->currentLog[$currentLogIndex]['voterDetails'][] = [ 'voter' => $voter, diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerTest.php index da9d18a43f18d..c3765a4ddc15c 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerTest.php @@ -58,14 +58,14 @@ public static function provideObjectsAndLogs(): \Generator yield [ [[ - 'attributes' => ['ATTRIBUTE_1'], - 'object' => null, - 'result' => true, - 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], - ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], - ], - ]], + 'attributes' => ['ATTRIBUTE_1'], + 'object' => null, + 'result' => true, + 'voterDetails' => [ + ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1'], 'vote' => VoterInterface::ACCESS_GRANTED], + ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1'], 'vote' => VoterInterface::ACCESS_GRANTED], + ], + ]], ['ATTRIBUTE_1'], null, [ @@ -76,14 +76,32 @@ public static function provideObjectsAndLogs(): \Generator ]; yield [ [[ - 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], - 'object' => true, - 'result' => false, - 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], - ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], - ], - ]], + 'attributes' => ['ATTRIBUTE_1'], + 'object' => null, + 'result' => true, + 'voterDetails' => [ + ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1'], 'vote' => VoterInterface::ACCESS_GRANTED], + ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], + ], + ]], + ['ATTRIBUTE_1'], + null, + [ + [$voter1, VoterInterface::ACCESS_GRANTED], + [$voter2, new Vote(VoterInterface::ACCESS_GRANTED)], + ], + true, + ]; + yield [ + [[ + 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], + 'object' => true, + 'result' => false, + 'voterDetails' => [ + ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_ABSTAIN], + ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_GRANTED], + ], + ]], ['ATTRIBUTE_1', 'ATTRIBUTE_2'], true, [ @@ -94,14 +112,32 @@ public static function provideObjectsAndLogs(): \Generator ]; yield [ [[ - 'attributes' => [null], - 'object' => 'jolie string', - 'result' => false, - 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => [null], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], - ['voter' => $voter2, 'attributes' => [null], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], - ], - ]], + 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], + 'object' => true, + 'result' => false, + 'voterDetails' => [ + ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], + ], + ]], + ['ATTRIBUTE_1', 'ATTRIBUTE_2'], + true, + [ + [$voter1, new Vote(VoterInterface::ACCESS_ABSTAIN)], + [$voter2, new Vote(VoterInterface::ACCESS_GRANTED)], + ], + false, + ]; + yield [ + [[ + 'attributes' => [null], + 'object' => 'jolie string', + 'result' => false, + 'voterDetails' => [ + ['voter' => $voter1, 'attributes' => [null], 'vote' => VoterInterface::ACCESS_ABSTAIN], + ['voter' => $voter2, 'attributes' => [null], 'vote' => VoterInterface::ACCESS_DENIED], + ], + ]], [null], 'jolie string', [ @@ -110,6 +146,24 @@ public static function provideObjectsAndLogs(): \Generator ], false, ]; + yield [ + [[ + 'attributes' => [null], + 'object' => 'jolie string', + 'result' => false, + 'voterDetails' => [ + ['voter' => $voter1, 'attributes' => [null], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ['voter' => $voter2, 'attributes' => [null], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], + ], + ]], + [null], + 'jolie string', + [ + [$voter1, new Vote(VoterInterface::ACCESS_ABSTAIN)], + [$voter2, new Vote(VoterInterface::ACCESS_DENIED)], + ], + false, + ]; yield [ [[ 'attributes' => [12], @@ -140,8 +194,8 @@ public static function provideObjectsAndLogs(): \Generator 'object' => $x = [], 'result' => false, 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], - ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_ABSTAIN], + ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_ABSTAIN], ], ]], ['ATTRIBUTE_2'], @@ -158,8 +212,8 @@ public static function provideObjectsAndLogs(): \Generator 'object' => new \stdClass(), 'result' => false, 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => [12.13], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], - ['voter' => $voter2, 'attributes' => [12.13], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], + ['voter' => $voter1, 'attributes' => [12.13], 'vote' => VoterInterface::ACCESS_DENIED], + ['voter' => $voter2, 'attributes' => [12.13], 'vote' => VoterInterface::ACCESS_DENIED], ], ]], [12.13], @@ -243,9 +297,9 @@ public function testAccessDecisionManagerCalledByVoter() 'attributes' => ['attr1'], 'object' => null, 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['attr1'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], - ['voter' => $voter2, 'attributes' => ['attr1'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], - ['voter' => $voter3, 'attributes' => ['attr1'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ['voter' => $voter1, 'attributes' => ['attr1'], 'vote' => VoterInterface::ACCESS_GRANTED], + ['voter' => $voter2, 'attributes' => ['attr1'], 'vote' => VoterInterface::ACCESS_ABSTAIN], + ['voter' => $voter3, 'attributes' => ['attr1'], 'vote' => VoterInterface::ACCESS_ABSTAIN], ], 'result' => true, ], @@ -253,9 +307,9 @@ public function testAccessDecisionManagerCalledByVoter() 'attributes' => ['attr2'], 'object' => null, 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], - ['voter' => $voter2, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], - ['voter' => $voter3, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ['voter' => $voter1, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_ABSTAIN], + ['voter' => $voter2, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_GRANTED], + ['voter' => $voter3, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_ABSTAIN], ], 'result' => true, ], @@ -263,9 +317,9 @@ public function testAccessDecisionManagerCalledByVoter() 'attributes' => ['attr2'], 'object' => $obj, 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], - ['voter' => $voter2, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], - ['voter' => $voter3, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], + ['voter' => $voter1, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_ABSTAIN], + ['voter' => $voter2, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_DENIED], + ['voter' => $voter3, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_GRANTED], ], 'result' => true, ], From a89224686b3fb561e46d49a89d4b258c371a4e49 Mon Sep 17 00:00:00 2001 From: eltharin Date: Fri, 10 Jan 2025 10:06:48 +0100 Subject: [PATCH 09/12] fabbot --- .../Component/Security/Core/Authorization/AccessDecision.php | 4 ++-- .../Component/Security/Core/Authorization/Voter/Vote.php | 2 +- .../Security/Core/Authorization/Voter/VoterInterface.php | 5 +---- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php b/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php index 0811d2779df7c..8f762143d2c72 100644 --- a/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php +++ b/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php @@ -23,8 +23,8 @@ final class AccessDecision { /** - * @param VoterInterface::ACCESS_*|int $access - * @param VoteInterface[] $votes + * @param VoterInterface::ACCESS_*|int $access + * @param VoteInterface[] $votes */ public function __construct( private readonly int $access, diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php b/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php index 098d8bc05f3e9..54733221e2a82 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php @@ -30,7 +30,7 @@ class Vote implements VoteInterface /** * @param VoterInterface::ACCESS_*|int $access One of the VoterInterface constants (ACCESS_GRANTED, ACCESS_ABSTAIN, ACCESS_DENIED) - * or an integer when scoring is false + * or an integer when scoring is false */ public function __construct( private int $access, diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php b/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php index 3ebb14eeefc9b..2f75aa0b4609a 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php @@ -32,11 +32,8 @@ interface VoterInterface * This method must return one of the following constants: * ACCESS_GRANTED, ACCESS_DENIED, or ACCESS_ABSTAIN. * - * @param TokenInterface $token - * @param mixed $subject The subject to secure + * @param mixed $subject The subject to secure * @param array $attributes An array of attributes associated with the method being invoked - * - * @return int */ public function vote(TokenInterface $token, mixed $subject, array $attributes): int; } From 7abffb178561c93b8cd0bf4852fdf72e44cbe420 Mon Sep 17 00:00:00 2001 From: eltharin Date: Fri, 10 Jan 2025 10:17:22 +0100 Subject: [PATCH 10/12] change tests --- ...ccessDecisionManagerWithVoteObjectTest.php | 20 +++++++++---------- .../Tests/Firewall/SwitchUserListenerTest.php | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerWithVoteObjectTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerWithVoteObjectTest.php index 5c54b0de0ff11..c8de8c9fed452 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerWithVoteObjectTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerWithVoteObjectTest.php @@ -74,8 +74,8 @@ public static function provideObjectsAndLogs(): \Generator ['ATTRIBUTE_1'], null, [ - [$voter1, VoterInterface::ACCESS_GRANTED], - [$voter2, VoterInterface::ACCESS_GRANTED], + [$voter1, new Vote(VoterInterface::ACCESS_GRANTED)], + [$voter2, new Vote(VoterInterface::ACCESS_GRANTED)], ], $result, ]; @@ -85,7 +85,7 @@ public static function provideObjectsAndLogs(): \Generator 'object' => true, 'result' => $result = new AccessDecision(VoterInterface::ACCESS_DENIED), 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_ABSTAIN], ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], ], ]], @@ -93,7 +93,7 @@ public static function provideObjectsAndLogs(): \Generator true, [ [$voter1, VoterInterface::ACCESS_ABSTAIN], - [$voter2, VoterInterface::ACCESS_GRANTED], + [$voter2, new Vote(VoterInterface::ACCESS_GRANTED)], ], $result, ]; @@ -110,8 +110,8 @@ public static function provideObjectsAndLogs(): \Generator [null], 'jolie string', [ - [$voter1, VoterInterface::ACCESS_ABSTAIN], - [$voter2, VoterInterface::ACCESS_DENIED], + [$voter1, new Vote(VoterInterface::ACCESS_ABSTAIN)], + [$voter2, new Vote(VoterInterface::ACCESS_DENIED)], ], $result, ]; @@ -152,8 +152,8 @@ public static function provideObjectsAndLogs(): \Generator ['ATTRIBUTE_2'], $x, [ - [$voter1, VoterInterface::ACCESS_ABSTAIN], - [$voter2, VoterInterface::ACCESS_ABSTAIN], + [$voter1, new Vote(VoterInterface::ACCESS_ABSTAIN)], + [$voter2, new Vote(VoterInterface::ACCESS_ABSTAIN)], ], $result, ]; @@ -170,8 +170,8 @@ public static function provideObjectsAndLogs(): \Generator [12.13], new \stdClass(), [ - [$voter1, VoterInterface::ACCESS_DENIED], - [$voter2, VoterInterface::ACCESS_DENIED], + [$voter1, new Vote(VoterInterface::ACCESS_DENIED)], + [$voter2, new Vote(VoterInterface::ACCESS_DENIED)], ], $result, ]; diff --git a/src/Symfony/Component/Security/Http/Tests/Firewall/SwitchUserListenerTest.php b/src/Symfony/Component/Security/Http/Tests/Firewall/SwitchUserListenerTest.php index 23b816cf04a53..ac41d312d56f2 100644 --- a/src/Symfony/Component/Security/Http/Tests/Firewall/SwitchUserListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Firewall/SwitchUserListenerTest.php @@ -220,7 +220,7 @@ public function testSwitchUserAlreadySwitched($accessDecisionManager, string $de $this->request->query->set('_switch_user', 'kuba'); $targetsUser = $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier()); - $this->accessDecisionManager->expects($this->once()) + $accessDecisionManager->expects($this->once()) ->method($decideFunction)->with(self::callback(function (TokenInterface $token) use ($originalToken, $tokenStorage) { // the token storage should also contain the original token for voters depending on it return $token === $originalToken && $tokenStorage->getToken() === $originalToken; From 23a03e249622dc85d739e0502c43e681568cacce Mon Sep 17 00:00:00 2001 From: eltharin Date: Tue, 11 Feb 2025 12:20:18 +0100 Subject: [PATCH 11/12] restart from 0 --- .../Controller/AbstractController.php | 24 +- .../Controller/AbstractControllerTest.php | 40 +- .../DataCollector/SecurityDataCollector.php | 10 +- .../DependencyInjection/MainConfiguration.php | 3 - .../DependencyInjection/SecurityExtension.php | 2 - .../EventListener/VoteListener.php | 2 +- .../views/Collector/security.html.twig | 476 +++++++++++------- .../Bundle/SecurityBundle/Security.php | 19 +- .../SecurityDataCollectorTest.php | 168 +++++-- .../Tests/EventListener/VoteListenerTest.php | 7 +- .../Core/Authorization/AccessDecision.php | 51 +- .../Authorization/AccessDecisionManager.php | 64 +-- .../AccessDecisionManagerInterface.php | 4 +- .../Authorization/AuthorizationChecker.php | 16 +- .../AuthorizationCheckerInterface.php | 4 +- .../AccessDecisionStrategyInterface.php | 7 +- ...essDecisionVoteObjectStrategyInterface.php | 28 ++ .../Strategy/AffirmativeStrategy.php | 36 +- .../Strategy/ConsensusStrategy.php | 41 +- .../Strategy/PriorityStrategy.php | 35 +- .../Strategy/ScoringStrategy.php | 65 --- .../Strategy/UnanimousStrategy.php | 36 +- .../TraceableAccessDecisionManager.php | 28 +- .../Voter/AuthenticatedVoter.php | 21 +- .../Authorization/Voter/ExpressionVoter.php | 11 +- .../Core/Authorization/Voter/RoleVoter.php | 11 +- .../Authorization/Voter/TraceableVoter.php | 17 +- .../Core/Authorization/Voter/Vote.php | 91 +--- .../Authorization/Voter/VoteInterface.php | 19 +- .../Core/Authorization/Voter/Voter.php | 29 +- .../Authorization/Voter/VoterInterface.php | 6 +- .../Component/Security/Core/CHANGELOG.md | 2 +- .../Security/Core/Event/VoteEvent.php | 15 +- .../Core/Exception/AccessDeniedException.php | 10 - .../Test/AccessDecisionStrategyTestCase.php | 45 +- .../AccessDecisionManagerTest.php | 335 +++++++----- .../AuthorizationCheckerTest.php | 156 +++--- .../Strategy/AffirmativeStrategyTest.php | 68 ++- .../Strategy/ConsensusStrategyTest.php | 122 ++--- .../Strategy/PriorityStrategyTest.php | 48 +- .../Strategy/ScoringStrategyTest.php | 168 ------- .../Strategy/UnanimousStrategyTest.php | 68 +-- .../TraceableAccessDecisionManagerTest.php | 128 ++--- ...ccessDecisionManagerWithVoteObjectTest.php | 295 ----------- .../Voter/AuthenticatedVoterTest.php | 10 - .../Voter/ExpressionVoterTest.php | 10 - .../Voter/RoleHierarchyVoterTest.php | 20 - .../Authorization/Voter/RoleVoterTest.php | 10 - .../Voter/TraceableVoterTest.php | 18 +- .../Tests/Authorization/Voter/VoterTest.php | 15 - .../Exception/AccessDeniedExceptionTest.php | 73 --- .../Core/Tests/Fixtures/DummyVoter.php | 5 - .../Tests/Fixtures/DummyVoterWithObject.php | 29 ++ .../IsGrantedAttributeListener.php | 12 +- .../Security/Http/Firewall/AccessListener.php | 17 +- .../Http/Firewall/SwitchUserListener.php | 17 +- .../IsGrantedAttributeListenerTest.php | 33 ++ .../Tests/Firewall/AccessListenerTest.php | 124 ++--- .../Tests/Firewall/SwitchUserListenerTest.php | 149 +++--- 59 files changed, 1427 insertions(+), 1946 deletions(-) create mode 100644 src/Symfony/Component/Security/Core/Authorization/Strategy/AccessDecisionVoteObjectStrategyInterface.php delete mode 100644 src/Symfony/Component/Security/Core/Authorization/Strategy/ScoringStrategy.php delete mode 100644 src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ScoringStrategyTest.php delete mode 100644 src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerWithVoteObjectTest.php delete mode 100644 src/Symfony/Component/Security/Core/Tests/Exception/AccessDeniedExceptionTest.php create mode 100644 src/Symfony/Component/Security/Core/Tests/Fixtures/DummyVoterWithObject.php diff --git a/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php b/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php index c6e28b3760d49..8a8742821a2c6 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php +++ b/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php @@ -37,7 +37,6 @@ use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; -use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Csrf\CsrfToken; @@ -205,17 +204,20 @@ protected function isGranted(mixed $attribute, mixed $subject = null): bool } /** - * Checks decision of the attribute against the current authentication token and optionally supplied subject. + * Checks if the attribute is granted against the current authentication token and optionally supplied subject. * * @throws \LogicException */ - protected function getDecision(mixed $attribute, mixed $subject = null): AccessDecision + protected function getAccessDecision(mixed $attribute, mixed $subject = null): AccessDecision { if (!$this->container->has('security.authorization_checker')) { throw new \LogicException('The SecurityBundle is not registered in your application. Try running "composer require symfony/security-bundle".'); } - return $this->container->get('security.authorization_checker')->getDecision($attribute, $subject); + $accessDecision = null; + $decision = $this->container->get('security.authorization_checker')->isGranted($attribute, $subject, $accessDecision); + + return null === $accessDecision ? new AccessDecision($decision) : $accessDecision; } /** @@ -226,23 +228,13 @@ protected function getDecision(mixed $attribute, mixed $subject = null): AccessD */ protected function denyAccessUnlessGranted(mixed $attribute, mixed $subject = null, string $message = 'Access Denied.'): void { - if (!$this->container->has('security.authorization_checker')) { - throw new \LogicException('The SecurityBundle is not registered in your application. Try running "composer require symfony/security-bundle".'); - } - - $checker = $this->container->get('security.authorization_checker'); - if (method_exists($checker, 'getDecision')) { - $decision = $checker->getDecision($attribute, $subject); - } else { - $decision = new AccessDecision($checker->isGranted($attribute, $subject) ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED); - } + $decision = $this->getAccessDecision($attribute, $subject); - if (!$decision->isGranted()) { + if ($decision->isDenied()) { $exception = $this->createAccessDeniedException($message); $exception->setAttributes([$attribute]); $exception->setSubject($subject); $exception->setAccessDecision($decision); - throw $exception; } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Controller/AbstractControllerTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Controller/AbstractControllerTest.php index 55a3639848c32..d3f03d9a3b5a9 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Controller/AbstractControllerTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Controller/AbstractControllerTest.php @@ -40,7 +40,9 @@ use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; @@ -362,7 +364,14 @@ public function testdenyAccessUnlessGranted() $this->expectException(AccessDeniedException::class); - $controller->denyAccessUnlessGranted('foo'); + try { + $controller->denyAccessUnlessGranted('foo'); + } catch (AccessDeniedException $exception) { + $this->assertFalse($exception->getAccessDecision()->getAccess()); + $this->assertEmpty($exception->getAccessDecision()->getVotes()); + $this->assertEmpty($exception->getAccessDecision()->getMessage()); + throw $exception; + } } /** @@ -644,4 +653,33 @@ public function testSendEarlyHints() $this->assertSame('; rel="preload"; as="stylesheet",; rel="preload"; as="script"', $response->headers->get('Link')); } + + public function testdenyAccessUnlessGrantedWithAccessDecisionObject() + { + $authorizationChecker = new class implements AuthorizationCheckerInterface { + public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision &$accessDecision = null): bool + { + $accessDecision = new AccessDecision(false, [new Vote(-1)], 'access denied'); + + return $accessDecision->getAccess(); + } + }; + + $container = new Container(); + $container->set('security.authorization_checker', $authorizationChecker); + + $controller = $this->createController(); + $controller->setContainer($container); + + $this->expectException(AccessDeniedException::class); + + try { + $controller->denyAccessUnlessGranted('foo'); + } catch (AccessDeniedException $exception) { + $this->assertFalse($exception->getAccessDecision()->getAccess()); + $this->assertCount(1, $exception->getAccessDecision()->getVotes()); + $this->assertSame('access denied', $exception->getAccessDecision()->getMessage()); + throw $exception; + } + } } diff --git a/src/Symfony/Bundle/SecurityBundle/DataCollector/SecurityDataCollector.php b/src/Symfony/Bundle/SecurityBundle/DataCollector/SecurityDataCollector.php index f3c1cd1fe34af..a24398ae6e51f 100644 --- a/src/Symfony/Bundle/SecurityBundle/DataCollector/SecurityDataCollector.php +++ b/src/Symfony/Bundle/SecurityBundle/DataCollector/SecurityDataCollector.php @@ -20,9 +20,12 @@ use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; use Symfony\Component\Security\Core\Authorization\TraceableAccessDecisionManager; use Symfony\Component\Security\Core\Authorization\Voter\TraceableVoter; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; +use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; use Symfony\Component\Security\Http\Firewall\SwitchUserListener; use Symfony\Component\Security\Http\FirewallMapInterface; @@ -138,6 +141,7 @@ public function collect(Request $request, Response $response, ?\Throwable $excep // collect voter details $decisionLog = $this->accessDecisionManager->getDecisionLog(); + foreach ($decisionLog as $key => $log) { $decisionLog[$key]['voter_details'] = []; foreach ($log['voterDetails'] as $voterDetail) { @@ -146,10 +150,14 @@ public function collect(Request $request, Response $response, ?\Throwable $excep $decisionLog[$key]['voter_details'][] = [ 'class' => $classData, 'attributes' => $voterDetail['attributes'], // Only displayed for unanimous strategy - 'vote' => $voterDetail['vote'], + 'vote' => $voterDetail['vote'] instanceof VoteInterface ? $voterDetail['vote'] : new Vote($voterDetail['vote']), ]; } unset($decisionLog[$key]['voterDetails']); + + if (!$decisionLog[$key]['result'] instanceof AccessDecision) { + $decisionLog[$key]['result'] = new AccessDecision($decisionLog[$key]['result']); + } } $this->data['access_decision_log'] = $decisionLog; diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php index 55398fd98e9bd..9854a1f047a7a 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php @@ -36,8 +36,6 @@ class MainConfiguration implements ConfigurationInterface public const STRATEGY_UNANIMOUS = 'unanimous'; /** @internal */ public const STRATEGY_PRIORITY = 'priority'; - /** @internal */ - public const STRATEGY_SCORING = 'scoring'; /** * @param array $factories @@ -475,7 +473,6 @@ private function getAccessDecisionStrategies(): array self::STRATEGY_CONSENSUS, self::STRATEGY_UNANIMOUS, self::STRATEGY_PRIORITY, - self::STRATEGY_SCORING, ]; } } diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index 8425d2a0471fe..dd1b8cdb490cc 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -52,7 +52,6 @@ use Symfony\Component\Security\Core\Authorization\Strategy\AffirmativeStrategy; use Symfony\Component\Security\Core\Authorization\Strategy\ConsensusStrategy; use Symfony\Component\Security\Core\Authorization\Strategy\PriorityStrategy; -use Symfony\Component\Security\Core\Authorization\Strategy\ScoringStrategy; use Symfony\Component\Security\Core\Authorization\Strategy\UnanimousStrategy; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\User\ChainUserChecker; @@ -195,7 +194,6 @@ private function createStrategyDefinition(string $strategy, bool $allowIfAllAbst MainConfiguration::STRATEGY_CONSENSUS => new Definition(ConsensusStrategy::class, [$allowIfAllAbstainDecisions, $allowIfEqualGrantedDeniedDecisions]), MainConfiguration::STRATEGY_UNANIMOUS => new Definition(UnanimousStrategy::class, [$allowIfAllAbstainDecisions]), MainConfiguration::STRATEGY_PRIORITY => new Definition(PriorityStrategy::class, [$allowIfAllAbstainDecisions]), - MainConfiguration::STRATEGY_SCORING => new Definition(ScoringStrategy::class, [$allowIfAllAbstainDecisions]), default => throw new InvalidConfigurationException(\sprintf('The strategy "%s" is not supported.', $strategy)), }; } diff --git a/src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php b/src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php index 2d29f7bd86728..33f1590926aa7 100644 --- a/src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php +++ b/src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php @@ -31,7 +31,7 @@ public function __construct( public function onVoterVote(VoteEvent $event): void { - $this->traceableAccessDecisionManager->addVoterVote($event->getVoter(), $event->getAttributes(), $event->getVoteObject()); + $this->traceableAccessDecisionManager->addVoterVote($event->getVoter(), $event->getAttributes(), $event->getVote(true)); } public static function getSubscribedEvents(): array diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig b/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig index ddd4a80f0fa11..5f00689179978 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig +++ b/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig @@ -29,10 +29,50 @@ padding: 0 0 8px 0; } + #collector-content .authenticator-name { + align-items: center; + display: flex; + gap: 16px; + } + + #collector-content .authenticators .toggle-button { + margin-left: auto; + } + #collector-content .authenticators .sf-toggle-on .toggle-button { + transform: rotate(180deg); + } + #collector-content .authenticators .toggle-button svg { + display: block; + } + + #collector-content .authenticators th, + #collector-content .authenticators td { + vertical-align: baseline; + } + #collector-content .authenticators th, + #collector-content .authenticators td { + vertical-align: baseline; + } + + #collector-content .authenticators .label { + display: block; + text-align: center; + } + + #collector-content .authenticator-data { + box-shadow: none; + margin: 0; + } + + #collector-content .authenticator-data tr:first-child th, + #collector-content .authenticator-data tr:first-child td { + border-top: 0; + } + #collector-content .authenticators .badge { color: var(--white); display: inline-block; - text-align: center; + margin: 4px 0; } #collector-content .authenticators .badge.badge-resolved { background-color: var(--green-500); @@ -40,13 +80,6 @@ #collector-content .authenticators .badge.badge-not_resolved { background-color: var(--yellow-500); } - - #collector-content .authenticators svg[data-icon-name="icon-tabler-check"] { - color: var(--green-500); - } - #collector-content .authenticators svg[data-icon-name="icon-tabler-x"] { - color: var(--red-500); - } {% endblock %} @@ -181,45 +214,64 @@ {{ source('@WebProfiler/Icon/' ~ (collector.authenticated ? 'yes' : 'no') ~ '.svg') }} Authenticated + + {% if collector.authProfileToken %} + + {% endif %} - - - - + + + + - - - + + - + {% if not collector.authenticated and collector.roles is empty %} +

User is not authenticated probably because they have no roles.

+ {% endif %} + + - {% if collector.supportsRoleHierarchy %} + {% if collector.supportsRoleHierarchy %} - {% endif %} + {% endif %} - {% if collector.token %} + {% if collector.token %} - {% endif %} + {% endif %}
PropertyValue
PropertyValue
Roles - {{ collector.roles is empty ? 'none' : profiler_dump(collector.roles, maxDepth=1) }} +
Roles + {{ collector.roles is empty ? 'none' : profiler_dump(collector.roles, maxDepth=1) }} - {% if not collector.authenticated and collector.roles is empty %} -

User is not authenticated probably because they have no roles.

- {% endif %} -
Inherited Roles {{ collector.inheritedRoles is empty ? 'none' : profiler_dump(collector.inheritedRoles, maxDepth=1) }}
Token {{ profiler_dump(collector.token) }}
{% elseif collector.enabled %}
-

There is no security token.

+

+ There is no security token. + {% if collector.deauthProfileToken %} + It was removed in + + {{- collector.deauthProfileToken -}} + . + {% endif %} +

{% endif %} @@ -248,40 +300,40 @@

Configuration

- - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyValue
KeyValue
provider{{ collector.firewall.provider ?: '(none)' }}
context{{ collector.firewall.context ?: '(none)' }}
entry_point{{ collector.firewall.entry_point ?: '(none)' }}
user_checker{{ collector.firewall.user_checker ?: '(none)' }}
access_denied_handler{{ collector.firewall.access_denied_handler ?: '(none)' }}
access_denied_url{{ collector.firewall.access_denied_url ?: '(none)' }}
authenticators{{ collector.firewall.authenticators is empty ? '(none)' : profiler_dump(collector.firewall.authenticators, maxDepth=1) }}
provider{{ collector.firewall.provider ?: '(none)' }}
context{{ collector.firewall.context ?: '(none)' }}
entry_point{{ collector.firewall.entry_point ?: '(none)' }}
user_checker{{ collector.firewall.user_checker ?: '(none)' }}
access_denied_handler{{ collector.firewall.access_denied_handler ?: '(none)' }}
access_denied_url{{ collector.firewall.access_denied_url ?: '(none)' }}
authenticators{{ collector.firewall.authenticators is empty ? '(none)' : profiler_dump(collector.firewall.authenticators, maxDepth=1) }}
{% endif %} @@ -299,11 +351,11 @@ {% else %} - - - - - + + + + + {% set previous_event = (collector.listeners|first) %} @@ -318,7 +370,7 @@ - + @@ -336,48 +388,90 @@
{% if collector.authenticators|default([]) is not empty %}
ListenerDurationResponse
ListenerDurationResponse
{{ profiler_dump(listener.stub) }}{{ '%0.2f'|format(listener.time * 1000) }} ms{{ listener.time is null ? '(none)' : '%0.2f ms'|format(listener.time * 1000) }} {{ listener.response ? profiler_dump(listener.response) : '(none)' }}
+ + + + - - - - - - - - - - - {% set previous_event = (collector.listeners|first) %} - {% for authenticator in collector.authenticators %} - {% if loop.first or authenticator != previous_event %} - {% if not loop.first %} - - {% endif %} - - - {% set previous_event = authenticator %} - {% endif %} - - - - - - - + + + + {% for i, authenticator in collector.authenticators %} + + + - - {% if loop.last %} - - {% endif %} {% endfor %}
AuthenticatorSupportsAuthenticatedDurationPassportBadges
{{ profiler_dump(authenticator.stub) }}{{ source('@WebProfiler/Icon/' ~ (authenticator.supports ? 'yes' : 'no') ~ '.svg') }}{{ authenticator.authenticated is not null ? source('@WebProfiler/Icon/' ~ (authenticator.authenticated ? 'yes' : 'no') ~ '.svg') : '' }}{{ '%0.2f'|format(authenticator.duration * 1000) }} ms{{ authenticator.passport ? profiler_dump(authenticator.passport) : '(none)' }} - {% for badge in authenticator.badges ?? [] %} - - {{ badge.stub|abbr_class }} - + StatusAuthenticator
+ {% if authenticator.authenticated %} + {% set status_text, label_status = 'success', 'success' %} + {% elseif authenticator.authenticated is null %} + {% set status_text, label_status = 'skipped', false %} {% else %} - (none) - {% endfor %} + {% set status_text, label_status = 'failure', 'error' %} + {% endif %} + {{ status_text }} + + + {{ profiler_dump(authenticator.stub) }} + + +
+ {% if authenticator.supports is same as(false) %} +
+

This authenticator did not support the request.

+
+ {% elseif authenticator.authenticated is null %} +
+

An authenticator ran before this one.

+
+ {% else %} + + + + + + + + + + + + + + {% if authenticator.passport %} + + + + + {% endif %} + {% if authenticator.badges %} + + + + + {% endif %} + {% if authenticator.exception %} + + + + + {% endif %} +
Lazy{{ authenticator.supports is null ? 'yes' : 'no' }}
Duration{{ '%0.2f ms'|format(authenticator.duration * 1000) }}
Passport{{ profiler_dump(authenticator.passport) }}
Badges + {% for badge in authenticator.badges %} + + {{ badge.stub|abbr_class }} + + {% endfor %} +
Exception{{ profiler_dump(authenticator.exception) }}
+ {% endif %} +
{% else %} @@ -401,98 +495,110 @@ - - - - + + + + - {% for voter in collector.voters %} - - - - - {% endfor %} + {% for voter in collector.voters %} + + + + + {% endfor %}
#Voter class
#Voter class
{{ loop.index }}{{ profiler_dump(voter) }}
{{ loop.index }}{{ profiler_dump(voter) }}
{% endif %} {% if collector.accessDecisionLog|default([]) is not empty %} -

Access decision log

- - - - - - - - - - - - - - - - - {% for decision in collector.accessDecisionLog %} - - - - - - - - - + + {% endfor %} + +
#ResultAttributesObject
{{ loop.index }} - - {{ decision.result.access == 1 - ? 'GRANTED' - : 'DENIED' - }} - {{ decision.result.message }} - - {% if decision.attributes|length == 1 %} - {% set attribute = decision.attributes|first %} - {% if attribute.expression is defined %} - Expression:
{{ attribute.expression }}
- {% elseif attribute.type == 'string' %} - {{ attribute }} - {% else %} - {{ profiler_dump(attribute) }} - {% endif %} - {% else %} - {{ profiler_dump(decision.attributes) }} - {% endif %} -
{{ profiler_dump(decision.seek('object')) }}
- {% if decision.voter_details is not empty %} - {% set voter_details_id = 'voter-details-' ~ loop.index %} -
- - - {% for voter_detail in decision.voter_details %} - - - {% if collector.voterStrategy == 'unanimous' %} +

Access decision log

+ +
{{ profiler_dump(voter_detail['class']) }}
+ + + + + + + + + + + + + + + + + + {% for decision in collector.accessDecisionLog %} + + + + + + + + + + - - {% endfor %} - -
#ResultAttributesObjectMessage
{{ loop.index }} + {{ decision.result.access + ? 'GRANTED' + : 'DENIED' + }} + + {% if decision.attributes|length == 1 %} + {% set attribute = decision.attributes|first %} + {% if attribute.expression is defined %} + Expression:
{{ attribute.expression }}
+ {% elseif attribute.type == 'string' %} + {{ attribute }} + {% else %} + {{ profiler_dump(attribute) }} + {% endif %} + {% else %} + {{ profiler_dump(decision.attributes) }} + {% endif %} +
{{ profiler_dump(decision.seek('object')) }}{{ decision.result.message }}
+ {% if decision.voter_details is not empty %} + {% set voter_details_id = 'voter-details-' ~ loop.index %} +
+ + + {% for voter_detail in decision.voter_details %} + + + {% if collector.voterStrategy == 'unanimous' %} - {% endif %} - - - - {% endfor %} - -
{{ profiler_dump(voter_detail['class']) }}attribute {{ voter_detail['attributes'][0] }} - {{ voter_detail['vote'].voteResultMessage }} - {{ voter_detail['vote'].message|default('~') }}
-
- Show voter details - {% endif %} -
-
+ {% endif %} +
+ {% if voter_detail['vote'].access == constant('Symfony\\Component\\Security\\Core\\Authorization\\Voter\\VoterInterface::ACCESS_GRANTED') %} + ACCESS GRANTED + {% elseif voter_detail['vote'].access == constant('Symfony\\Component\\Security\\Core\\Authorization\\Voter\\VoterInterface::ACCESS_ABSTAIN') %} + ACCESS ABSTAIN + {% elseif voter_detail['vote'].access == constant('Symfony\\Component\\Security\\Core\\Authorization\\Voter\\VoterInterface::ACCESS_DENIED') %} + ACCESS DENIED + {% else %} + unknown ({{ voter_detail['vote'].access }}) + {% endif %} + {% if voter_detail['vote'].messages is not empty %} + : {{ voter_detail['vote'].messages | join(', ') }} + {% endif %} +
+ + Show voter details + {% endif %} + + + {% endfor %} + + + {% endif %} diff --git a/src/Symfony/Bundle/SecurityBundle/Security.php b/src/Symfony/Bundle/SecurityBundle/Security.php index 1821b019115c3..8c4457adc2188 100644 --- a/src/Symfony/Bundle/SecurityBundle/Security.php +++ b/src/Symfony/Bundle/SecurityBundle/Security.php @@ -20,7 +20,6 @@ use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface; -use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Exception\LogicException; use Symfony\Component\Security\Core\Exception\LogoutException; use Symfony\Component\Security\Core\User\UserInterface; @@ -60,22 +59,10 @@ public function getUser(): ?UserInterface /** * Checks if the attributes are granted against the current authentication token and optionally supplied subject. */ - public function isGranted(mixed $attributes, mixed $subject = null): bool + public function isGranted(mixed $attributes, mixed $subject = null, ?AccessDecision $accessDecision = null): bool { - return $this->getDecision($attributes, $subject)->isGranted(); - } - - /** - * Gets the access decision against the current authentication token and optionally supplied subject. - */ - public function getDecision(mixed $attribute, mixed $subject = null): AccessDecision - { - $checker = $this->container->get('security.authorization_checker'); - if (!method_exists($checker, 'getDecision')) { - return new AccessDecision($checker->isGranted($attribute, $subject) ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED); - } - - return $checker->getDecision($attribute, $subject); + return $this->container->get('security.authorization_checker') + ->isGranted($attributes, $subject, $accessDecision); } public function getToken(): ?TokenInterface diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DataCollector/SecurityDataCollectorTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DataCollector/SecurityDataCollectorTest.php index bf23c0032f890..856e9c13ae7ff 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DataCollector/SecurityDataCollectorTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DataCollector/SecurityDataCollectorTest.php @@ -26,8 +26,11 @@ use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\TraceableAccessDecisionManager; use Symfony\Component\Security\Core\Authorization\Voter\TraceableVoter; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; +use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Role\RoleHierarchy; use Symfony\Component\Security\Core\User\InMemoryUser; @@ -225,13 +228,102 @@ public function testCollectCollectsDecisionLogWhenStrategyIsAffirmative() { $voter1 = new DummyVoter(); $voter2 = new DummyVoter(); + $voter3 = new DummyVoterWithObject(); + + $decoratedVoter = function (VoterInterface $voter) { + return new TraceableVoter( + $voter, new class implements EventDispatcherInterface { + public function dispatch(object $event, ?string $eventName = null): object + { + return new \stdClass(); + } + } + ); + }; + + $strategy = MainConfiguration::STRATEGY_AFFIRMATIVE; + + $accessDecisionManager = $this->createMock(TraceableAccessDecisionManager::class); + + $accessDecisionManager + ->method('getStrategy') + ->willReturn($strategy); + + $accessDecisionManager + ->method('getVoters') + ->willReturn([ + $decoratedVoter($voter1), + $decoratedVoter($voter2), + $decoratedVoter($voter3), + ]); + + $accessDecisionManager + ->method('getDecisionLog') + ->willReturn([[ + 'attributes' => ['view'], + 'object' => new \stdClass(), + 'result' => new AccessDecision(true), + 'voterDetails' => [ + ['voter' => $voter1, 'attributes' => ['view'], 'vote' => VoterInterface::ACCESS_ABSTAIN], + ['voter' => $voter2, 'attributes' => ['view'], 'vote' => VoterInterface::ACCESS_ABSTAIN], + ['voter' => $voter3, 'attributes' => ['view'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ], + ]]); - $decoratedVoter1 = new TraceableVoter($voter1, new class implements EventDispatcherInterface { - public function dispatch(object $event, ?string $eventName = null): object - { - return new \stdClass(); - } - }); + $dataCollector = new SecurityDataCollector(null, null, null, $accessDecisionManager, null, null, true); + + $dataCollector->collect(new Request(), new Response()); + + $actualDecisionLog = $dataCollector->getAccessDecisionLog(); + + $expectedDecisionLog = [[ + 'attributes' => ['view'], + 'object' => new \stdClass(), + 'result' => new AccessDecision(true), + 'voter_details' => [ + ['class' => $voter1::class, 'attributes' => ['view'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ['class' => $voter2::class, 'attributes' => ['view'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ['class' => $voter3::class, 'attributes' => ['view'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ], + ]]; + + $this->assertEquals($actualDecisionLog, $expectedDecisionLog, 'Wrong value returned by getAccessDecisionLog'); + + $actualVoterClasses = array_map(static function (ClassStub $classStub): string { + return (string) $classStub; + }, $dataCollector->getVoters()); + + $expectedVoterClasses = [ + $voter1::class, + $voter2::class, + $voter3::class, + ]; + + $this->assertSame( + $actualVoterClasses, + $expectedVoterClasses, + 'Wrong value returned by getVoters' + ); + + $this->assertSame($dataCollector->getVoterStrategy(), $strategy, 'Wrong value returned by getVoterStrategy'); + } + + public function testCollectCollectsDecisionLogWhenStrategyIsAffirmativeWithVoterMessage() + { + $voter1 = new DummyVoter(); + $voter2 = new DummyVoter(); + $voter3 = new DummyVoterWithObject(); + + $decoratedVoter = function (VoterInterface $voter) { + return new TraceableVoter( + $voter, new class implements EventDispatcherInterface { + public function dispatch(object $event, ?string $eventName = null): object + { + return new \stdClass(); + } + } + ); + }; $strategy = MainConfiguration::STRATEGY_AFFIRMATIVE; @@ -244,8 +336,9 @@ public function dispatch(object $event, ?string $eventName = null): object $accessDecisionManager ->method('getVoters') ->willReturn([ - $decoratedVoter1, - $decoratedVoter1, + $decoratedVoter($voter1), + $decoratedVoter($voter2), + $decoratedVoter($voter3), ]); $accessDecisionManager @@ -257,6 +350,7 @@ public function dispatch(object $event, ?string $eventName = null): object 'voterDetails' => [ ['voter' => $voter1, 'attributes' => ['view'], 'vote' => VoterInterface::ACCESS_ABSTAIN], ['voter' => $voter2, 'attributes' => ['view'], 'vote' => VoterInterface::ACCESS_ABSTAIN], + ['voter' => $voter3, 'attributes' => ['view'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN, ['voter message'])], ], ]]); @@ -269,10 +363,11 @@ public function dispatch(object $event, ?string $eventName = null): object $expectedDecisionLog = [[ 'attributes' => ['view'], 'object' => new \stdClass(), - 'result' => true, + 'result' => new AccessDecision(true), 'voter_details' => [ - ['class' => $voter1::class, 'attributes' => ['view'], 'vote' => VoterInterface::ACCESS_ABSTAIN], - ['class' => $voter2::class, 'attributes' => ['view'], 'vote' => VoterInterface::ACCESS_ABSTAIN], + ['class' => $voter1::class, 'attributes' => ['view'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ['class' => $voter2::class, 'attributes' => ['view'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], + ['class' => $voter3::class, 'attributes' => ['view'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN, ['voter message'])], ], ]]; @@ -285,6 +380,7 @@ public function dispatch(object $event, ?string $eventName = null): object $expectedVoterClasses = [ $voter1::class, $voter2::class, + $voter3::class, ]; $this->assertSame( @@ -300,13 +396,18 @@ public function testCollectCollectsDecisionLogWhenStrategyIsUnanimous() { $voter1 = new DummyVoter(); $voter2 = new DummyVoter(); - - $decoratedVoter1 = new TraceableVoter($voter1, new class implements EventDispatcherInterface { - public function dispatch(object $event, ?string $eventName = null): object - { - return new \stdClass(); - } - }); + $voter3 = new DummyVoterWithObject(); + + $decoratedVoter = function (VoterInterface $voter) { + return new TraceableVoter( + $voter, new class implements EventDispatcherInterface { + public function dispatch(object $event, ?string $eventName = null): object + { + return new \stdClass(); + } + } + ); + }; $strategy = MainConfiguration::STRATEGY_UNANIMOUS; @@ -319,8 +420,9 @@ public function dispatch(object $event, ?string $eventName = null): object $accessDecisionManager ->method('getVoters') ->willReturn([ - $decoratedVoter1, - $decoratedVoter1, + $decoratedVoter($voter1), + $decoratedVoter($voter2), + $decoratedVoter($voter3), ]); $accessDecisionManager @@ -335,6 +437,8 @@ public function dispatch(object $event, ?string $eventName = null): object ['voter' => $voter1, 'attributes' => ['edit'], 'vote' => VoterInterface::ACCESS_DENIED], ['voter' => $voter2, 'attributes' => ['view'], 'vote' => VoterInterface::ACCESS_GRANTED], ['voter' => $voter2, 'attributes' => ['edit'], 'vote' => VoterInterface::ACCESS_GRANTED], + ['voter' => $voter3, 'attributes' => ['edit'], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], + ['voter' => $voter3, 'attributes' => ['edit'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], ], ], [ @@ -358,21 +462,23 @@ public function dispatch(object $event, ?string $eventName = null): object [ 'attributes' => ['view', 'edit'], 'object' => new \stdClass(), - 'result' => false, + 'result' => new AccessDecision(false), 'voter_details' => [ - ['class' => $voter1::class, 'attributes' => ['view'], 'vote' => VoterInterface::ACCESS_DENIED], - ['class' => $voter1::class, 'attributes' => ['edit'], 'vote' => VoterInterface::ACCESS_DENIED], - ['class' => $voter2::class, 'attributes' => ['view'], 'vote' => VoterInterface::ACCESS_GRANTED], - ['class' => $voter2::class, 'attributes' => ['edit'], 'vote' => VoterInterface::ACCESS_GRANTED], + ['class' => $voter1::class, 'attributes' => ['view'], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], + ['class' => $voter1::class, 'attributes' => ['edit'], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], + ['class' => $voter2::class, 'attributes' => ['view'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], + ['class' => $voter2::class, 'attributes' => ['edit'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], + ['class' => $voter3::class, 'attributes' => ['edit'], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], + ['class' => $voter3::class, 'attributes' => ['edit'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], ], ], [ 'attributes' => ['update'], 'object' => new \stdClass(), - 'result' => true, + 'result' => new AccessDecision(true), 'voter_details' => [ - ['class' => $voter1::class, 'attributes' => ['update'], 'vote' => VoterInterface::ACCESS_GRANTED], - ['class' => $voter2::class, 'attributes' => ['update'], 'vote' => VoterInterface::ACCESS_GRANTED], + ['class' => $voter1::class, 'attributes' => ['update'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], + ['class' => $voter2::class, 'attributes' => ['update'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], ], ], ]; @@ -386,6 +492,7 @@ public function dispatch(object $event, ?string $eventName = null): object $expectedVoterClasses = [ $voter1::class, $voter2::class, + $voter3::class, ]; $this->assertSame( @@ -464,8 +571,11 @@ final class DummyVoter implements VoterInterface public function vote(TokenInterface $token, mixed $subject, array $attributes): int { } +} - public function getVote(TokenInterface $token, mixed $subject, array $attributes): VoterInterface +final class DummyVoterWithObject implements VoterInterface +{ + public function vote(TokenInterface $token, mixed $subject, array $attributes, ?VoteInterface $vote = null): int { } } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/EventListener/VoteListenerTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/EventListener/VoteListenerTest.php index a90a4cd601532..9328d0ae2a03a 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/EventListener/VoteListenerTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/EventListener/VoteListenerTest.php @@ -33,7 +33,7 @@ public function testOnVoterVote() $traceableAccessDecisionManager ->expects($this->once()) ->method('addVoterVote') - ->with($voter, ['myattr1', 'myattr2'], new Vote(VoterInterface::ACCESS_GRANTED)); + ->with($voter, ['myattr1', 'myattr2'], VoterInterface::ACCESS_GRANTED); $sut = new VoteListener($traceableAccessDecisionManager); $sut->onVoterVote(new VoteEvent($voter, 'mysubject', ['myattr1', 'myattr2'], VoterInterface::ACCESS_GRANTED)); @@ -49,13 +49,12 @@ public function testOnVoterVoteWithObject() ->onlyMethods(['addVoterVote']) ->getMock(); - $vote = new Vote(VoterInterface::ACCESS_GRANTED); $traceableAccessDecisionManager ->expects($this->once()) ->method('addVoterVote') - ->with($voter, ['myattr1', 'myattr2'], $vote); + ->with($voter, ['myattr1', 'myattr2'], new Vote(VoterInterface::ACCESS_GRANTED)); $sut = new VoteListener($traceableAccessDecisionManager); - $sut->onVoterVote(new VoteEvent($voter, 'mysubject', ['myattr1', 'myattr2'], $vote)); + $sut->onVoterVote(new VoteEvent($voter, 'mysubject', ['myattr1', 'myattr2'], new Vote(VoterInterface::ACCESS_GRANTED))); } } diff --git a/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php b/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php index 8f762143d2c72..403320494e2b1 100644 --- a/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php +++ b/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php @@ -12,7 +12,6 @@ namespace Symfony\Component\Security\Core\Authorization; use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; -use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; /** * An AccessDecision is returned by an AccessDecisionManager and contains the access verdict and all the related votes. @@ -23,34 +22,28 @@ final class AccessDecision { /** - * @param VoterInterface::ACCESS_*|int $access - * @param VoteInterface[] $votes + * @param VoteInterface[]|int[] $votes */ public function __construct( - private readonly int $access, + private readonly bool $access, private readonly array $votes = [], private readonly string $message = '', ) { } - public function getAccess(): int + public function getAccess(): bool { return $this->access; } public function isGranted(): bool { - return VoterInterface::ACCESS_GRANTED === $this->access; - } - - public function isAbstainer(): bool - { - return VoterInterface::ACCESS_ABSTAIN === $this->access; + return true === $this->access; } public function isDenied(): bool { - return VoterInterface::ACCESS_DENIED === $this->access; + return false === $this->access; } public function getMessage(): string @@ -59,42 +52,10 @@ public function getMessage(): string } /** - * @return VoteInterface[] + * @return VoteInterface[]|int[] */ public function getVotes(): array { return $this->votes; } - - /** - * @return VoteInterface[] - */ - public function getGrantedVotes(): array - { - return $this->getVotesByAccess(VoterInterface::ACCESS_GRANTED); - } - - /** - * @return VoteInterface[] - */ - public function getAbstainerVotes(): array - { - return $this->getVotesByAccess(VoterInterface::ACCESS_ABSTAIN); - } - - /** - * @return VoteInterface[] - */ - public function getDeniedVotes(): array - { - return $this->getVotesByAccess(VoterInterface::ACCESS_DENIED); - } - - /** - * @return VoteInterface[] - */ - private function getVotesByAccess(int $access): array - { - return array_filter($this->votes, static fn ($vote) => $vote->getAccess() === $access); - } } diff --git a/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php b/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php index 3438c7423e317..9a473fae544b0 100644 --- a/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php +++ b/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php @@ -13,9 +13,10 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Strategy\AccessDecisionStrategyInterface; +use Symfony\Component\Security\Core\Authorization\Strategy\AccessDecisionVoteObjectStrategyInterface; use Symfony\Component\Security\Core\Authorization\Strategy\AffirmativeStrategy; use Symfony\Component\Security\Core\Authorization\Voter\CacheableVoterInterface; -use Symfony\Component\Security\Core\Authorization\Voter\Vote; +use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Exception\InvalidArgumentException; @@ -27,6 +28,12 @@ */ final class AccessDecisionManager implements AccessDecisionManagerInterface { + private const VALID_VOTES = [ + VoterInterface::ACCESS_GRANTED => true, + VoterInterface::ACCESS_DENIED => true, + VoterInterface::ACCESS_ABSTAIN => true, + ]; + private array $votersCacheAttributes = []; private array $votersCacheObject = []; private AccessDecisionStrategyInterface $strategy; @@ -41,62 +48,37 @@ public function __construct( $this->strategy = $strategy ?? new AffirmativeStrategy(); } - public function getDecision(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false): AccessDecision - { - if (\count($attributes) > 1 && !$allowMultipleAttributes) { - throw new InvalidArgumentException(\sprintf('Passing more than one Security attribute to "%s()" is not supported.', __METHOD__)); - } - - if (!method_exists($this->strategy, 'getDecision')) { - $decision = new AccessDecision( - $this->strategy->decide($this->collectResults($token, $attributes, $object)) - ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED - ); - } else { - $decision = $this->strategy->getDecision( - $this->collectVotes($token, $attributes, $object) - ); - } - - return $decision; - } - /** * @param bool $allowMultipleAttributes Whether to allow passing multiple values to the $attributes array */ - public function decide(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false): bool + public function decide(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false, ?AccessDecision &$accessDecision = null): bool { + // Special case for AccessListener, do not remove the right side of the condition before 6.0 if (\count($attributes) > 1 && !$allowMultipleAttributes) { throw new InvalidArgumentException(\sprintf('Passing more than one Security attribute to "%s()" is not supported.', __METHOD__)); } - - return $this->strategy->decide( - $this->collectResults($token, $attributes, $object) + $decision = $this->strategy->decide( + $this->collectResults($token, $attributes, $object, $this->strategy instanceof AccessDecisionVoteObjectStrategyInterface), + $accessDecision, ); + + return $decision; } /** - * @return \Traversable + * @return \Traversable */ - private function collectVotes(TokenInterface $token, array $attributes, mixed $object): \Traversable + private function collectResults(TokenInterface $token, array $attributes, mixed $object, bool $returnAsObject): \Traversable { foreach ($this->getVoters($attributes, $object) as $voter) { - if (!method_exists($voter, 'getVote')) { - yield new Vote($voter->vote($token, $object, $attributes)); - } else { - yield $voter->getVote($token, $object, $attributes); + $vote = null; + $result = $voter->vote($token, $object, $attributes, $vote); + + if (!($vote instanceof VoteInterface) && (!\is_int($result) || !(self::VALID_VOTES[$result] ?? false))) { + throw new \LogicException(\sprintf('"%s::vote()" must return one of "%s" constants ("ACCESS_GRANTED", "ACCESS_DENIED" or "ACCESS_ABSTAIN"), "%s" returned.', get_debug_type($voter), VoterInterface::class, var_export($result, true))); } - } - } - /** - * @return \Traversable - */ - private function collectResults(TokenInterface $token, array $attributes, mixed $object): \Traversable - { - /** @var Vote $vote */ - foreach ($this->collectVotes($token, $attributes, $object) as $vote) { - yield $vote->getAccess(); + yield $returnAsObject && null !== $vote ? $vote : $result; } } diff --git a/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManagerInterface.php b/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManagerInterface.php index 6dcdf8c3dfd20..1eccc1ee04ff0 100644 --- a/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManagerInterface.php +++ b/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManagerInterface.php @@ -17,8 +17,6 @@ * AccessDecisionManagerInterface makes authorization decisions. * * @author Fabien Potencier - * - * @method AccessDecision getDecision(TokenInterface $token, array $attributes, mixed $object = null) */ interface AccessDecisionManagerInterface { @@ -28,5 +26,5 @@ interface AccessDecisionManagerInterface * @param array $attributes An array of attributes associated with the method being invoked * @param mixed $object The object to secure */ - public function decide(TokenInterface $token, array $attributes, mixed $object = null): bool; + public function decide(TokenInterface $token, array $attributes, mixed $object = null/* , bool $allowMultipleAttributes = false, ?AccessDecision &$accessDecision = null */): bool; } diff --git a/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php b/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php index 77e0ad6310b08..e0371e414243f 100644 --- a/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php +++ b/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php @@ -13,7 +13,6 @@ use Symfony\Component\Security\Core\Authentication\Token\NullToken; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; /** * AuthorizationChecker is the main authorization point of the Security component. @@ -31,23 +30,16 @@ public function __construct( ) { } - final public function isGranted(mixed $attribute, mixed $subject = null): bool - { - return $this->getDecision($attribute, $subject)->isGranted(); - } - - final public function getDecision($attribute, $subject = null): AccessDecision + final public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision &$accessDecision = null): bool { $token = $this->tokenStorage->getToken(); if (!$token || !$token->getUser()) { $token = new NullToken(); } + $accessDecision = null; + $decision = $this->accessDecisionManager->decide($token, [$attribute], $subject, false, $accessDecision); - if (!method_exists($this->accessDecisionManager, 'getDecision')) { - return new AccessDecision($this->accessDecisionManager->decide($token, [$attribute], $subject) ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED); - } - - return $this->accessDecisionManager->getDecision($token, [$attribute], $subject); + return $decision; } } diff --git a/src/Symfony/Component/Security/Core/Authorization/AuthorizationCheckerInterface.php b/src/Symfony/Component/Security/Core/Authorization/AuthorizationCheckerInterface.php index 0089b2b1dbf6e..ce19df5784058 100644 --- a/src/Symfony/Component/Security/Core/Authorization/AuthorizationCheckerInterface.php +++ b/src/Symfony/Component/Security/Core/Authorization/AuthorizationCheckerInterface.php @@ -15,8 +15,6 @@ * The AuthorizationCheckerInterface. * * @author Johannes M. Schmitt - * - * @method AccessDecision getDecision(mixed $attribute, mixed $subject = null) */ interface AuthorizationCheckerInterface { @@ -25,5 +23,5 @@ interface AuthorizationCheckerInterface * * @param mixed $attribute A single attribute to vote on (can be of any type, string and instance of Expression are supported by the core) */ - public function isGranted(mixed $attribute, mixed $subject = null): bool; + public function isGranted(mixed $attribute, mixed $subject = null/* , ?AccessDecision &$accessDecision = null */): bool; } diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/AccessDecisionStrategyInterface.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/AccessDecisionStrategyInterface.php index 2e7f0cbfac5b5..4e80f36c38666 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Strategy/AccessDecisionStrategyInterface.php +++ b/src/Symfony/Component/Security/Core/Authorization/Strategy/AccessDecisionStrategyInterface.php @@ -12,18 +12,17 @@ namespace Symfony\Component\Security\Core\Authorization\Strategy; use Symfony\Component\Security\Core\Authorization\AccessDecision; +use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; /** * A strategy for turning a stream of votes into a final decision. * * @author Alexander M. Turek - * - * @method AccessDecision getDecision(\Traversable $votes) */ interface AccessDecisionStrategyInterface { /** - * @param \Traversable $results + * @param \Traversable $results */ - public function decide(\Traversable $results): bool; + public function decide(\Traversable $results/* , ?AccessDecision &$accessDecision */): bool; } diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/AccessDecisionVoteObjectStrategyInterface.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/AccessDecisionVoteObjectStrategyInterface.php new file mode 100644 index 0000000000000..e4471d82df5a0 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Authorization/Strategy/AccessDecisionVoteObjectStrategyInterface.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Authorization\Strategy; + +use Symfony\Component\Security\Core\Authorization\AccessDecision; +use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; + +/** + * A strategy for turning a stream of votes into a final decision. + * + * @author Alexander M. Turek + */ +interface AccessDecisionVoteObjectStrategyInterface extends AccessDecisionStrategyInterface +{ + /** + * @param \Traversable $results + */ + public function decide(\Traversable $results, ?AccessDecision &$accessDecision = null): bool; +} diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/AffirmativeStrategy.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/AffirmativeStrategy.php index 0c4a7b4007099..be42ef811a823 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Strategy/AffirmativeStrategy.php +++ b/src/Symfony/Component/Security/Core/Authorization/Strategy/AffirmativeStrategy.php @@ -12,7 +12,6 @@ namespace Symfony\Component\Security\Core\Authorization\Strategy; use Symfony\Component\Security\Core\Authorization\AccessDecision; -use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; @@ -25,41 +24,44 @@ * @author Fabien Potencier * @author Alexander M. Turek */ -final class AffirmativeStrategy implements AccessDecisionStrategyInterface, \Stringable +final class AffirmativeStrategy implements AccessDecisionVoteObjectStrategyInterface, \Stringable { public function __construct( private bool $allowIfAllAbstainDecisions = false, ) { } - public function decide(\Traversable $results): bool + public function decide(\Traversable $results, ?AccessDecision &$accessDecision = null): bool { - return $this->getDecision(new \ArrayIterator(array_map(fn ($vote) => new Vote($vote), iterator_to_array($results))))->isGranted(); - } - - public function getDecision(\Traversable $votes): AccessDecision - { - $currentVotes = []; $deny = 0; + $allVotes = []; - /** @var VoteInterface $vote */ - foreach ($votes as $vote) { - $currentVotes[] = $vote; + foreach ($results as $result) { + $allVotes[] = $result; + if ($result instanceof VoteInterface) { + $result = $result->getAccess(); + } - if ($vote->isGranted()) { - return new AccessDecision(VoterInterface::ACCESS_GRANTED, $currentVotes); + if (VoterInterface::ACCESS_GRANTED === $result) { + $accessDecision = new AccessDecision(true, $allVotes); + + return $accessDecision->getAccess(); } - if ($vote->isDenied()) { + if (VoterInterface::ACCESS_DENIED === $result) { ++$deny; } } if ($deny > 0) { - return new AccessDecision(VoterInterface::ACCESS_DENIED, $currentVotes); + $accessDecision = new AccessDecision(false, $allVotes); + + return $accessDecision->getAccess(); } - return new AccessDecision($this->allowIfAllAbstainDecisions ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, $currentVotes); + $accessDecision = new AccessDecision($this->allowIfAllAbstainDecisions, $allVotes); + + return $accessDecision->getAccess(); } public function __toString(): string diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/ConsensusStrategy.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/ConsensusStrategy.php index 8779ef82e08aa..6946ed4909cb7 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Strategy/ConsensusStrategy.php +++ b/src/Symfony/Component/Security/Core/Authorization/Strategy/ConsensusStrategy.php @@ -12,7 +12,6 @@ namespace Symfony\Component\Security\Core\Authorization\Strategy; use Symfony\Component\Security\Core\Authorization\AccessDecision; -use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; @@ -33,7 +32,7 @@ * @author Fabien Potencier * @author Alexander M. Turek */ -final class ConsensusStrategy implements AccessDecisionStrategyInterface, \Stringable +final class ConsensusStrategy implements AccessDecisionVoteObjectStrategyInterface, \Stringable { public function __construct( private bool $allowIfAllAbstainDecisions = false, @@ -41,40 +40,46 @@ public function __construct( ) { } - public function decide(\Traversable $results): bool + public function decide(\Traversable $results, ?AccessDecision &$accessDecision = null): bool { - return $this->getDecision(new \ArrayIterator(array_map(fn ($vote) => new Vote($vote), iterator_to_array($results))))->isGranted(); - } - - public function getDecision(\Traversable $votes): AccessDecision - { - $currentVotes = []; $grant = 0; $deny = 0; + $allVotes = []; + + foreach ($results as $result) { + $allVotes[] = $result; + if ($result instanceof VoteInterface) { + $result = $result->getAccess(); + } - /** @var VoteInterface $vote */ - foreach ($votes as $vote) { - $currentVotes[] = $vote; - if ($vote->isGranted()) { + if (VoterInterface::ACCESS_GRANTED === $result) { ++$grant; - } elseif ($vote->isDenied()) { + } elseif (VoterInterface::ACCESS_DENIED === $result) { ++$deny; } } if ($grant > $deny) { - return new AccessDecision(VoterInterface::ACCESS_GRANTED, $currentVotes); + $accessDecision = new AccessDecision(true, $allVotes); + + return $accessDecision->getAccess(); } if ($deny > $grant) { - return new AccessDecision(VoterInterface::ACCESS_DENIED, $currentVotes); + $accessDecision = new AccessDecision(false, $allVotes); + + return $accessDecision->getAccess(); } if ($grant > 0) { - return new AccessDecision($this->allowIfEqualGrantedDeniedDecisions ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, $currentVotes); + $accessDecision = new AccessDecision($this->allowIfEqualGrantedDeniedDecisions, $allVotes); + + return $accessDecision->getAccess(); } - return new AccessDecision($this->allowIfAllAbstainDecisions ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, $currentVotes); + $accessDecision = new AccessDecision($this->allowIfAllAbstainDecisions, $allVotes); + + return $accessDecision->getAccess(); } public function __toString(): string diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/PriorityStrategy.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/PriorityStrategy.php index 67896f3c4897b..9632b8411ecb3 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Strategy/PriorityStrategy.php +++ b/src/Symfony/Component/Security/Core/Authorization/Strategy/PriorityStrategy.php @@ -12,7 +12,6 @@ namespace Symfony\Component\Security\Core\Authorization\Strategy; use Symfony\Component\Security\Core\Authorization\AccessDecision; -use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; @@ -26,35 +25,39 @@ * @author Fabien Potencier * @author Alexander M. Turek */ -final class PriorityStrategy implements AccessDecisionStrategyInterface, \Stringable +final class PriorityStrategy implements AccessDecisionVoteObjectStrategyInterface, \Stringable { public function __construct( private bool $allowIfAllAbstainDecisions = false, ) { } - public function decide(\Traversable $results): bool + public function decide(\Traversable $results, ?AccessDecision &$accessDecision = null): bool { - return $this->getDecision(new \ArrayIterator(array_map(fn ($vote) => new Vote($vote), iterator_to_array($results))))->isGranted(); - } + $allVotes = []; - public function getDecision(\Traversable $votes): AccessDecision - { - $currentVotes = []; + foreach ($results as $result) { + $allVotes[] = $result; + if ($result instanceof VoteInterface) { + $result = $result->getAccess(); + } - /** @var VoteInterface $vote */ - foreach ($votes as $vote) { - $currentVotes[] = $vote; - if ($vote->isGranted()) { - return new AccessDecision(VoterInterface::ACCESS_GRANTED, $currentVotes); + if (VoterInterface::ACCESS_GRANTED === $result) { + $accessDecision = new AccessDecision(true, $allVotes); + + return $accessDecision->getAccess(); } - if ($vote->isDenied()) { - return new AccessDecision(VoterInterface::ACCESS_DENIED, $currentVotes); + if (VoterInterface::ACCESS_DENIED === $result) { + $accessDecision = new AccessDecision(false, $allVotes); + + return $accessDecision->getAccess(); } } - return new AccessDecision($this->allowIfAllAbstainDecisions ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, $currentVotes); + $accessDecision = new AccessDecision($this->allowIfAllAbstainDecisions, $allVotes); + + return $accessDecision->getAccess(); } public function __toString(): string diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/ScoringStrategy.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/ScoringStrategy.php deleted file mode 100644 index efbe2b3ff4ecf..0000000000000 --- a/src/Symfony/Component/Security/Core/Authorization/Strategy/ScoringStrategy.php +++ /dev/null @@ -1,65 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Core\Authorization\Strategy; - -use Symfony\Component\Security\Core\Authorization\AccessDecision; -use Symfony\Component\Security\Core\Authorization\Voter\Vote; -use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; -use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; - -/** - * Grants access if the sum of vote results is greater than 0. - * - * If all voters abstained from voting, the decision will be based on the - * allowIfAllAbstainDecisions property value (defaults to false). - * - * @author Roman JOLY - */ -final class ScoringStrategy implements AccessDecisionStrategyInterface, \Stringable -{ - public function __construct( - private bool $allowIfAllAbstainDecisions = false, - ) { - } - - public function decide(\Traversable $results): bool - { - return $this->getDecision(new \ArrayIterator(array_map(fn ($vote) => new Vote($vote), iterator_to_array($results))))->isGranted(); - } - - public function getDecision(\Traversable $votes): AccessDecision - { - $currentVotes = []; - $score = 0; - - /** @var VoteInterface $vote */ - foreach ($votes as $vote) { - $currentVotes[] = $vote; - $score += $vote->getAccess(); - } - - if ($score > 0) { - return new AccessDecision(VoterInterface::ACCESS_GRANTED, $currentVotes, 'score = '.$score); - } - - if ($score < 0) { - return new AccessDecision(VoterInterface::ACCESS_DENIED, $currentVotes, 'score = '.$score); - } - - return new AccessDecision($this->allowIfAllAbstainDecisions ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, $currentVotes); - } - - public function __toString(): string - { - return 'scoring'; - } -} diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/UnanimousStrategy.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/UnanimousStrategy.php index 0208dc991ae39..ba048e86fb2d6 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Strategy/UnanimousStrategy.php +++ b/src/Symfony/Component/Security/Core/Authorization/Strategy/UnanimousStrategy.php @@ -12,7 +12,6 @@ namespace Symfony\Component\Security\Core\Authorization\Strategy; use Symfony\Component\Security\Core\Authorization\AccessDecision; -use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; @@ -25,42 +24,45 @@ * @author Fabien Potencier * @author Alexander M. Turek */ -final class UnanimousStrategy implements AccessDecisionStrategyInterface, \Stringable +final class UnanimousStrategy implements AccessDecisionVoteObjectStrategyInterface, \Stringable { public function __construct( private bool $allowIfAllAbstainDecisions = false, ) { } - public function decide(\Traversable $results): bool + public function decide(\Traversable $results, ?AccessDecision &$accessDecision = null): bool { - return $this->getDecision(new \ArrayIterator(array_map(fn ($vote) => new Vote($vote), iterator_to_array($results))))->isGranted(); - } - - public function getDecision(\Traversable $votes): AccessDecision - { - $currentVotes = []; $grant = 0; + $allVotes = []; - /** @var VoteInterface $vote */ - foreach ($votes as $vote) { - $currentVotes[] = $vote; + foreach ($results as $result) { + $allVotes[] = $result; + if ($result instanceof VoteInterface) { + $result = $result->getAccess(); + } - if ($vote->isDenied()) { - return new AccessDecision(VoterInterface::ACCESS_DENIED, $currentVotes); + if (VoterInterface::ACCESS_DENIED === $result) { + $accessDecision = new AccessDecision(false, $allVotes); + + return $accessDecision->getAccess(); } - if ($vote->isGranted()) { + if (VoterInterface::ACCESS_GRANTED === $result) { ++$grant; } } // no deny votes if ($grant > 0) { - return new AccessDecision(VoterInterface::ACCESS_GRANTED, $currentVotes); + $accessDecision = new AccessDecision(true, $allVotes); + + return $accessDecision->getAccess(); } - return new AccessDecision($this->allowIfAllAbstainDecisions ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, $currentVotes); + $accessDecision = new AccessDecision($this->allowIfAllAbstainDecisions, $allVotes); + + return $accessDecision->getAccess(); } public function __toString(): string diff --git a/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php b/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php index 6e6195e072ebf..70481c5e7607d 100644 --- a/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php +++ b/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php @@ -13,7 +13,6 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Strategy\AccessDecisionStrategyInterface; -use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; @@ -47,7 +46,7 @@ public function __construct( } } - public function getDecision(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false): AccessDecision + public function decide(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false, ?AccessDecision &$accessDecision = null): bool { $currentDecisionLog = [ 'attributes' => $attributes, @@ -57,26 +56,7 @@ public function getDecision(TokenInterface $token, array $attributes, mixed $obj $this->currentLog[] = &$currentDecisionLog; - $result = $this->manager->getDecision($token, $attributes, $object, $allowMultipleAttributes); - - $currentDecisionLog['result'] = $result; - - $this->decisionLog[] = array_pop($this->currentLog); // Using a stack since getDecision can be called by voters - - return $result; - } - - public function decide(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false): bool - { - $currentDecisionLog = [ - 'attributes' => $attributes, - 'object' => $object, - 'voterDetails' => [], - ]; - - $this->currentLog[] = &$currentDecisionLog; - - $result = $this->manager->decide($token, $attributes, $object, $allowMultipleAttributes); + $result = $this->manager->decide($token, $attributes, $object, $allowMultipleAttributes, $accessDecision); $currentDecisionLog['result'] = $result; @@ -88,8 +68,8 @@ public function decide(TokenInterface $token, array $attributes, mixed $object = /** * Adds voter vote and class to the voter details. * - * @param array $attributes attributes used for the vote - * @param VoteInterface|int $vote vote of the voter + * @param array $attributes attributes used for the vote + * @param int $vote vote of the voter */ public function addVoterVote(VoterInterface $voter, array $attributes, VoteInterface|int $vote): void { diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/AuthenticatedVoter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/AuthenticatedVoter.php index 45aaa4ceabf8c..a073f6168472a 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/AuthenticatedVoter.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/AuthenticatedVoter.php @@ -41,17 +41,12 @@ public function __construct( } public function vote(TokenInterface $token, mixed $subject, array $attributes): int - { - return $this->getVote($token, $subject, $attributes)->getAccess(); - } - - public function getVote(TokenInterface $token, mixed $subject, array $attributes): VoteInterface { if ($attributes === [self::PUBLIC_ACCESS]) { - return new Vote(VoterInterface::ACCESS_GRANTED); + return VoterInterface::ACCESS_GRANTED; } - $result = new Vote(VoterInterface::ACCESS_ABSTAIN); + $result = VoterInterface::ACCESS_ABSTAIN; foreach ($attributes as $attribute) { if (null === $attribute || (self::IS_AUTHENTICATED_FULLY !== $attribute && self::IS_AUTHENTICATED_REMEMBERED !== $attribute @@ -65,29 +60,29 @@ public function getVote(TokenInterface $token, mixed $subject, array $attributes throw new InvalidArgumentException('Cannot decide on authentication attributes when an offline token is used.'); } - $result = new Vote(VoterInterface::ACCESS_DENIED); + $result = VoterInterface::ACCESS_DENIED; if (self::IS_AUTHENTICATED_FULLY === $attribute && $this->authenticationTrustResolver->isFullFledged($token)) { - return new Vote(VoterInterface::ACCESS_GRANTED); + return VoterInterface::ACCESS_GRANTED; } if (self::IS_AUTHENTICATED_REMEMBERED === $attribute && ($this->authenticationTrustResolver->isRememberMe($token) || $this->authenticationTrustResolver->isFullFledged($token))) { - return new Vote(VoterInterface::ACCESS_GRANTED); + return VoterInterface::ACCESS_GRANTED; } if (self::IS_AUTHENTICATED === $attribute && $this->authenticationTrustResolver->isAuthenticated($token)) { - return new Vote(VoterInterface::ACCESS_GRANTED); + return VoterInterface::ACCESS_GRANTED; } if (self::IS_REMEMBERED === $attribute && $this->authenticationTrustResolver->isRememberMe($token)) { - return new Vote(VoterInterface::ACCESS_GRANTED); + return VoterInterface::ACCESS_GRANTED; } if (self::IS_IMPERSONATOR === $attribute && $token instanceof SwitchUserToken) { - return new Vote(VoterInterface::ACCESS_GRANTED); + return VoterInterface::ACCESS_GRANTED; } } diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php index ccffca71a3b4b..bab328307ac84 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php @@ -46,12 +46,7 @@ public function supportsType(string $subjectType): bool public function vote(TokenInterface $token, mixed $subject, array $attributes): int { - return $this->getVote($token, $subject, $attributes)->getAccess(); - } - - public function getVote(TokenInterface $token, mixed $subject, array $attributes): VoteInterface - { - $result = new Vote(VoterInterface::ACCESS_ABSTAIN); + $result = VoterInterface::ACCESS_ABSTAIN; $variables = null; foreach ($attributes as $attribute) { if (!$attribute instanceof Expression) { @@ -60,9 +55,9 @@ public function getVote(TokenInterface $token, mixed $subject, array $attributes $variables ??= $this->getVariables($token, $subject); - $result = new Vote(VoterInterface::ACCESS_DENIED); + $result = VoterInterface::ACCESS_DENIED; if ($this->expressionLanguage->evaluate($attribute, $variables)) { - return new Vote(VoterInterface::ACCESS_GRANTED); + return VoterInterface::ACCESS_GRANTED; } } diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/RoleVoter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/RoleVoter.php index dccbfa4dbbc42..3c65fb634c047 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/RoleVoter.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/RoleVoter.php @@ -27,12 +27,7 @@ public function __construct( public function vote(TokenInterface $token, mixed $subject, array $attributes): int { - return $this->getVote($token, $subject, $attributes)->getAccess(); - } - - public function getVote(TokenInterface $token, mixed $subject, array $attributes): VoteInterface - { - $result = new Vote(VoterInterface::ACCESS_ABSTAIN); + $result = VoterInterface::ACCESS_ABSTAIN; $roles = $this->extractRoles($token); foreach ($attributes as $attribute) { @@ -40,9 +35,9 @@ public function getVote(TokenInterface $token, mixed $subject, array $attributes continue; } - $result = new Vote(VoterInterface::ACCESS_DENIED); + $result = VoterInterface::ACCESS_DENIED; if (\in_array($attribute, $roles, true)) { - return new Vote(VoterInterface::ACCESS_GRANTED); + return VoterInterface::ACCESS_GRANTED; } } diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/TraceableVoter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/TraceableVoter.php index 364a6e6fcd8d5..281924f2a29a8 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/TraceableVoter.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/TraceableVoter.php @@ -30,22 +30,13 @@ public function __construct( ) { } - public function vote(TokenInterface $token, mixed $subject, array $attributes): int + public function vote(TokenInterface $token, mixed $subject, array $attributes, ?VoteInterface &$vote = null): int { - return $this->getVote($token, $subject, $attributes)->getAccess(); - } - - public function getVote(TokenInterface $token, mixed $subject, array $attributes): VoteInterface - { - if (method_exists($this->voter, 'getVote')) { - $vote = $this->voter->getVote($token, $subject, $attributes); - } else { - $vote = new Vote($this->voter->vote($token, $subject, $attributes)); - } + $result = $this->voter->vote($token, $subject, $attributes, $vote); - $this->eventDispatcher->dispatch(new VoteEvent($this->voter, $subject, $attributes, $vote), 'debug.security.authorization.vote'); + $this->eventDispatcher->dispatch(new VoteEvent($this->voter, $subject, $attributes, $vote ?? $result), 'debug.security.authorization.vote'); - return $vote; + return $result; } public function getDecoratedVoter(): VoterInterface diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php b/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php index 54733221e2a82..b26aef74c4207 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php @@ -11,111 +11,52 @@ namespace Symfony\Component\Security\Core\Authorization\Voter; -use Symfony\Component\Security\Core\Exception\InvalidArgumentException; - /** - * A Vote is returned by a Voter and contains the access result (granted, abstain or denied). - * It can also contain one or multiple messages explaining the vote decision. + * A Vote is returned by a Voter and contains the access and some messages. * - * @author Dany Maillard - * @author Antoine Lamirault * @author Roman JOLY */ class Vote implements VoteInterface { - /** - * @var string[] - */ - private array $messages; + private const VALID_VOTES = [ + VoterInterface::ACCESS_GRANTED => true, + VoterInterface::ACCESS_DENIED => true, + VoterInterface::ACCESS_ABSTAIN => true, + ]; - /** - * @param VoterInterface::ACCESS_*|int $access One of the VoterInterface constants (ACCESS_GRANTED, ACCESS_ABSTAIN, ACCESS_DENIED) - * or an integer when scoring is false - */ public function __construct( private int $access, - string|array $messages = [], - private array $context = [], - private bool $scoring = false, + private string|array $messages = [], ) { - if (!$scoring && !\in_array($access, [VoterInterface::ACCESS_GRANTED, VoterInterface::ACCESS_ABSTAIN, VoterInterface::ACCESS_DENIED], true)) { - throw new \LogicException(\sprintf('"$access" must return one of "%s" constants ("ACCESS_GRANTED", "ACCESS_DENIED" or "ACCESS_ABSTAIN") when "$scoring" is false, "%s" returned.', VoterInterface::class, $access)); + if (!\in_array($access, [VoterInterface::ACCESS_GRANTED, VoterInterface::ACCESS_ABSTAIN, VoterInterface::ACCESS_DENIED], true)) { + throw new \LogicException(\sprintf('"$access" must return one of "%s" constants ("ACCESS_GRANTED", "ACCESS_DENIED" or "ACCESS_ABSTAIN"), "%s" returned.', VoterInterface::class, $access)); } $this->setMessages($messages); } - public function __debugInfo(): array - { - return [ - 'message' => $this->getMessage(), - 'context' => $this->context, - 'voteResultMessage' => $this->getVoteResultMessage(), - ]; - } - public function getAccess(): int { return $this->access; } - public function isGranted(): bool - { - return $this->access > 0; - } - - public function isAbstainer(): bool - { - return VoterInterface::ACCESS_ABSTAIN === $this->access; - } - - public function isDenied(): bool + /** + * @return string[] + */ + public function getMessages(): array { - return $this->access < 0; + return $this->messages; } /** - * @param string|string[] $messages + * @throws \InvalidArgumentException */ public function setMessages(string|array $messages): void { $this->messages = (array) $messages; foreach ($this->messages as $message) { if (!\is_string($message)) { - throw new InvalidArgumentException(\sprintf('Message must be string, "%s" given.', get_debug_type($message))); + throw new \InvalidArgumentException(\sprintf('Message must be string, "%s" given.', get_debug_type($message))); } } } - - public function addMessage(string $message): void - { - $this->messages[] = $message; - } - - /** - * @return string[] - */ - public function getMessages(): array - { - return $this->messages; - } - - public function getMessage(): string - { - return implode(', ', $this->messages); - } - - public function getVoteResultMessage(): string - { - return $this->scoring ? 'SCORE : '.$this->access : match ($this->access) { - VoterInterface::ACCESS_GRANTED => 'ACCESS GRANTED', - VoterInterface::ACCESS_DENIED => 'ACCESS DENIED', - VoterInterface::ACCESS_ABSTAIN => 'ACCESS ABSTAIN', - default => 'UNKNOWN ACCESS TYPE', - }; - } - - public function getContext(): array - { - return $this->context; - } } diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/VoteInterface.php b/src/Symfony/Component/Security/Core/Authorization/Voter/VoteInterface.php index 6d4cd7125cc7b..a79a8e1a93917 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/VoteInterface.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/VoteInterface.php @@ -12,25 +12,16 @@ namespace Symfony\Component\Security\Core\Authorization\Voter; /** - * A VoteInterface object contain information about vote, access/score, messages. + * A VoteInterface implemented object can be returned by a Voter instead simple int for add some data, messages or other. * * @author Roman JOLY */ interface VoteInterface { - public function __debugInfo(): array; - public function getAccess(): int; - public function isGranted(): bool; - - public function isAbstainer(): bool; - - public function isDenied(): bool; - - public function getMessage(): string; - - public function getVoteResultMessage(): string; - - public function getContext(): array; + /** + * @return string[] + */ + public function getMessages(): array; } diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php index 1fb5bc6fd089a..1f76a42eaf1b8 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php @@ -24,10 +24,10 @@ */ abstract class Voter implements VoterInterface, CacheableVoterInterface { - public function getVote(TokenInterface $token, mixed $subject, array $attributes): VoteInterface + public function vote(TokenInterface $token, mixed $subject, array $attributes): int { // abstain vote by default in case none of the attributes are supported - $vote = new Vote(VoterInterface::ACCESS_ABSTAIN); + $vote = self::ACCESS_ABSTAIN; foreach ($attributes as $attribute) { try { @@ -43,34 +43,17 @@ public function getVote(TokenInterface $token, mixed $subject, array $attributes } // as soon as at least one attribute is supported, default is to deny access - if (!$vote->isDenied()) { - $vote = new Vote(VoterInterface::ACCESS_DENIED); - } - - $decision = $this->voteOnAttribute($attribute, $subject, $token); + $vote = self::ACCESS_DENIED; - if (\is_bool($decision)) { - $decision = new Vote($decision); - } - - if ($decision->isGranted()) { + if ($this->voteOnAttribute($attribute, $subject, $token)) { // grant access as soon as at least one attribute returns a positive response - return $decision; - } - - if ('' !== $decisionMessage = $decision->getMessage()) { - $vote->addMessage($decisionMessage); + return self::ACCESS_GRANTED; } } return $vote; } - public function vote(TokenInterface $token, mixed $subject, array $attributes): int - { - return $this->getVote($token, $subject, $attributes)->getAccess(); - } - /** * Return false if your voter doesn't support the given attribute. Symfony will cache * that decision and won't call your voter again for that attribute. @@ -108,5 +91,5 @@ abstract protected function supports(string $attribute, mixed $subject): bool; * @param TAttribute $attribute * @param TSubject $subject */ - abstract protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): VoteInterface|bool; + abstract protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool; } diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php b/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php index 2f75aa0b4609a..644959fd5e1c5 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php @@ -17,8 +17,6 @@ * VoterInterface is the interface implemented by all voters. * * @author Fabien Potencier - * - * @method VoteInterface getVote(TokenInterface $token, mixed $subject, array $attributes) */ interface VoterInterface { @@ -34,6 +32,8 @@ interface VoterInterface * * @param mixed $subject The subject to secure * @param array $attributes An array of attributes associated with the method being invoked + * + * @return self::ACCESS_* */ - public function vote(TokenInterface $token, mixed $subject, array $attributes): int; + public function vote(TokenInterface $token, mixed $subject, array $attributes/* , ?VoteInterface &$vote = null */): int; } diff --git a/src/Symfony/Component/Security/Core/CHANGELOG.md b/src/Symfony/Component/Security/Core/CHANGELOG.md index 7abde6433e7d1..1cdf7a0090c23 100644 --- a/src/Symfony/Component/Security/Core/CHANGELOG.md +++ b/src/Symfony/Component/Security/Core/CHANGELOG.md @@ -9,7 +9,7 @@ CHANGELOG * Add `OfflineTokenInterface` to mark tokens that do not represent the currently logged-in user * Deprecate `UserInterface::eraseCredentials()` and `TokenInterface::eraseCredentials()`, erase credentials e.g. using `__serialize()` instead - * Add the ability for voter to return decision reason and a score by passing a `Vote` object + * Add the ability for voter to return decision reason by passing a `Vote` object 7.2 --- diff --git a/src/Symfony/Component/Security/Core/Event/VoteEvent.php b/src/Symfony/Component/Security/Core/Event/VoteEvent.php index abe2d8bfd6a21..b8482bfe3308d 100644 --- a/src/Symfony/Component/Security/Core/Event/VoteEvent.php +++ b/src/Symfony/Component/Security/Core/Event/VoteEvent.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Security\Core\Event; -use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Contracts\EventDispatcher\Event; @@ -25,15 +24,12 @@ */ final class VoteEvent extends Event { - private VoteInterface $vote; - public function __construct( private VoterInterface $voter, private mixed $subject, private array $attributes, - VoteInterface|int $vote, + private VoteInterface|int $vote, ) { - $this->vote = $vote instanceof VoteInterface ? $vote : new Vote($vote); } public function getVoter(): VoterInterface @@ -51,13 +47,12 @@ public function getAttributes(): array return $this->attributes; } - public function getVote(): int + public function getVote($asObject = false): VoteInterface|int { - return $this->vote->getAccess(); - } + if ($this->vote instanceof VoteInterface && !$asObject) { + return $this->vote->getAccess(); + } - public function getVoteObject(): VoteInterface - { return $this->vote; } } diff --git a/src/Symfony/Component/Security/Core/Exception/AccessDeniedException.php b/src/Symfony/Component/Security/Core/Exception/AccessDeniedException.php index 1e33836a2ddc0..04273bf303363 100644 --- a/src/Symfony/Component/Security/Core/Exception/AccessDeniedException.php +++ b/src/Symfony/Component/Security/Core/Exception/AccessDeniedException.php @@ -13,7 +13,6 @@ use Symfony\Component\HttpKernel\Attribute\WithHttpStatus; use Symfony\Component\Security\Core\Authorization\AccessDecision; -use Symfony\Component\Security\Core\Authorization\Voter\Vote; /** * AccessDeniedException is thrown when the account has not the required role. @@ -58,15 +57,6 @@ public function setSubject(mixed $subject): void public function setAccessDecision(AccessDecision $accessDecision): void { $this->accessDecision = $accessDecision; - if (!$deniedVotes = $accessDecision->getDeniedVotes()) { - return; - } - - $messages = array_map(static fn (Vote $vote): string => \sprintf('%s', $vote->getMessage()), $deniedVotes); - - if (!empty(array_filter($messages))) { - $this->message .= \sprintf(\PHP_EOL.'Decision message%s "%s"', \count($messages) > 1 ? 's are' : ' is', implode('" and "', $messages)); - } } public function getAccessDecision(): ?AccessDecision diff --git a/src/Symfony/Component/Security/Core/Test/AccessDecisionStrategyTestCase.php b/src/Symfony/Component/Security/Core/Test/AccessDecisionStrategyTestCase.php index 35554f1c16b64..552bdabb54c51 100644 --- a/src/Symfony/Component/Security/Core/Test/AccessDecisionStrategyTestCase.php +++ b/src/Symfony/Component/Security/Core/Test/AccessDecisionStrategyTestCase.php @@ -18,8 +18,8 @@ use Symfony\Component\Security\Core\Authorization\AccessDecisionManager; use Symfony\Component\Security\Core\Authorization\Strategy\AccessDecisionStrategyInterface; use Symfony\Component\Security\Core\Authorization\Voter\Vote; +use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; -use Symfony\Component\Security\Core\Exception\LogicException; /** * Abstract test case for access decision strategies. @@ -34,25 +34,19 @@ abstract class AccessDecisionStrategyTestCase extends TestCase * @param VoterInterface[] $voters */ #[DataProvider('provideStrategyTests')] - final public function testDecide(AccessDecisionStrategyInterface $strategy, array $voters, AccessDecision $expected) + final public function testDecide(AccessDecisionStrategyInterface $strategy, array $voters, bool $expected, array $votesExpected) { $token = $this->createMock(TokenInterface::class); $manager = new AccessDecisionManager($voters, $strategy); - $this->assertSame($expected->isGranted(), $manager->decide($token, ['ROLE_FOO'])); - } + $accessDecision = null; + $decision = $manager->decide($token, ['ROLE_FOO'], null, false, $accessDecision); - /** - * @dataProvider provideStrategyTests - * - * @param VoterInterface[] $voters - */ - final public function testGetDecision(AccessDecisionStrategyInterface $strategy, array $voters, AccessDecision $expected) - { - $token = $this->createMock(TokenInterface::class); - $manager = new AccessDecisionManager($voters, $strategy); + $this->assertSame($expected, $decision); - $this->assertEquals($expected, $manager->getDecision($token, ['ROLE_FOO'])); + $this->assertInstanceOf(AccessDecision::class, $accessDecision); + $this->assertSame($expected, $accessDecision->getAccess()); + $this->assertEquals($votesExpected, $accessDecision->getVotes()); } /** @@ -68,15 +62,12 @@ final protected static function getVoters(int $grants, int $denies, int $abstain $voters = []; for ($i = 0; $i < $grants; ++$i) { $voters[] = static::getVoter(VoterInterface::ACCESS_GRANTED); - $voters[] = static::getVoterWithVoteObject(VoterInterface::ACCESS_GRANTED); } for ($i = 0; $i < $denies; ++$i) { $voters[] = static::getVoter(VoterInterface::ACCESS_DENIED); - $voters[] = static::getVoterWithVoteObject(VoterInterface::ACCESS_DENIED); } for ($i = 0; $i < $abstains; ++$i) { $voters[] = static::getVoter(VoterInterface::ACCESS_ABSTAIN); - $voters[] = static::getVoterWithVoteObject(VoterInterface::ACCESS_ABSTAIN); } return $voters; @@ -94,11 +85,6 @@ public function vote(TokenInterface $token, $subject, array $attributes): int { return $this->vote; } - - public function __call($function, $args) - { - throw new LogicException('This function must not be acceded.'); - } }; } @@ -110,21 +96,12 @@ public function __construct( ) { } - public function vote(TokenInterface $token, $subject, array $attributes): int + public function vote(TokenInterface $token, $subject, array $attributes, ?VoteInterface &$vote = null): int { - return $this->vote; - } + $vote = new Vote($this->vote); - public function getVote(TokenInterface $token, mixed $subject, array $attributes): Vote - { - return new Vote($this->vote); + return $this->vote; } }; } - - final protected static function getAccessDecision(bool $decision, array $votes): AccessDecision - { - return new AccessDecision($decision ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, - array_map(fn ($v) => new Vote($v), $votes)); - } } diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php index 0cfdd453886ce..8c32ebe3603b2 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php @@ -17,10 +17,12 @@ use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AccessDecisionManager; use Symfony\Component\Security\Core\Authorization\Strategy\AccessDecisionStrategyInterface; +use Symfony\Component\Security\Core\Authorization\Strategy\AccessDecisionVoteObjectStrategyInterface; use Symfony\Component\Security\Core\Authorization\Voter\CacheableVoterInterface; use Symfony\Component\Security\Core\Authorization\Voter\Vote; +use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; -use Symfony\Component\Security\Core\Exception\LogicException; +use Symfony\Component\Security\Core\Tests\Fixtures\DummyVoterWithObject; class AccessDecisionManagerTest extends TestCase { @@ -32,106 +34,192 @@ public function provideBadVoterResults(): array ]; } - public function provideDataWithAndWithoutVoteObject() + public function testVoterCalls() { - yield [ - 'useVoteObject' => false, - 'decideFunction' => 'decide', - 'voteFunction' => 'vote', - 'excpectedCallback' => fn ($a) => $a, - ]; + $token = $this->createMock(TokenInterface::class); - yield [ - 'useVoteObject' => true, - 'decideFunction' => 'getDecision', - 'voteFunction' => 'getVote', - 'excpectedCallback' => fn ($access, $votes = []) => new AccessDecision( - $access ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, - $votes - ), + $voters = [ + $this->getExpectedVoter(VoterInterface::ACCESS_DENIED), + $this->getExpectedVoter(VoterInterface::ACCESS_GRANTED), + $this->getExpectedVoter(VoterInterface::ACCESS_DENIED), + $this->getUnexpectedVoter(), ]; + + $strategy = new class implements AccessDecisionStrategyInterface { + public function decide(\Traversable $results): bool + { + $i = 0; + foreach ($results as $result) { + switch ($i++) { + case 0: + Assert::assertSame(VoterInterface::ACCESS_DENIED, $result); + + break; + case 1: + Assert::assertSame(VoterInterface::ACCESS_GRANTED, $result); + + break; + case 2: + Assert::assertSame(VoterInterface::ACCESS_DENIED, $result); + + return true; + } + } + + return false; + } + }; + + $manager = new AccessDecisionManager($voters, $strategy); + + $this->assertTrue($manager->decide($token, ['ROLE_FOO'])); } - public function createVoterMock(bool $useVoteObject) + public function testVoterCallsWithAccessDecisionObject() { - return $useVoteObject ? - $this->getMockBuilder(CacheableVoterInterface::class) - ->onlyMethods(['supportsAttribute', 'supportsType', 'vote']) - ->addMethods(['getVote']) - ->getMock(): - $this->createMock(CacheableVoterInterface::class); + $token = $this->createMock(TokenInterface::class); + + $voters = [ + $this->getExpectedVoter(VoterInterface::ACCESS_DENIED), + $this->getExpectedVoter(VoterInterface::ACCESS_GRANTED), + new DummyVoterWithObject(new Vote(VoterInterface::ACCESS_DENIED)), + $this->getUnexpectedVoter(), + ]; + + $strategy = new class implements AccessDecisionVoteObjectStrategyInterface { + public function decide(\Traversable $results, ?AccessDecision &$accessDecision = null): bool + { + $i = 0; + foreach ($results as $result) { + switch ($i++) { + case 0: + Assert::assertSame(VoterInterface::ACCESS_DENIED, $result); + + break; + case 1: + Assert::assertSame(VoterInterface::ACCESS_GRANTED, $result); + + break; + case 2: + Assert::assertInstanceOf(VoteInterface::class, $result); + Assert::assertSame(VoterInterface::ACCESS_DENIED, $result->getAccess()); + $accessDecision = new AccessDecision(true, [$result]); + + return true; + } + } + + return false; + } + }; + + $manager = new AccessDecisionManager($voters, $strategy); + $accessDecision = null; + + $decision = $manager->decide($token, ['ROLE_FOO'], null, false, $accessDecision); + $this->assertInstanceOf(AccessDecision::class, $accessDecision); + $this->assertTrue($decision); + $this->assertTrue($accessDecision->getAccess()); + $this->assertEmpty($accessDecision->getMessage()); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testVoterCalls($useVoteObject, $decideFunction, $voteFunction, $excpectedCallback) + public function testVoterCallsWithAccessDecisionObjectAndStrategyWithoutObject() { $token = $this->createMock(TokenInterface::class); $voters = [ $this->getExpectedVoter(VoterInterface::ACCESS_DENIED), $this->getExpectedVoter(VoterInterface::ACCESS_GRANTED), + new DummyVoterWithObject(new Vote(VoterInterface::ACCESS_DENIED)), $this->getUnexpectedVoter(), ]; - if ($useVoteObject) { - $strategy = new class() implements AccessDecisionStrategyInterface { - public function decide(\Traversable $results): bool { throw new LogicException('Method should not be called'); } // never call - public function getDecision(\Traversable $votes): AccessDecision - { - $i = 0; - foreach ($votes as $vote) { - switch ($i++) { - case 0: - Assert::assertSame(VoterInterface::ACCESS_DENIED, $vote->getAccess()); - - break; - case 1: - Assert::assertSame(VoterInterface::ACCESS_GRANTED, $vote->getAccess()); - - return new AccessDecision(VoterInterface::ACCESS_GRANTED); - } + $strategy = new class implements AccessDecisionStrategyInterface { + public function decide(\Traversable $results, ?AccessDecision &$accessDecision = null): bool + { + $i = 0; + foreach ($results as $result) { + switch ($i++) { + case 0: + Assert::assertSame(VoterInterface::ACCESS_DENIED, $result); + + break; + case 1: + Assert::assertSame(VoterInterface::ACCESS_GRANTED, $result); + + break; + case 2: + Assert::assertNotInstanceOf(VoteInterface::class, $result); + Assert::assertSame(VoterInterface::ACCESS_DENIED, $result); + + return true; } - - return new AccessDecision(VoterInterface::ACCESS_DENIED); } - }; - } else { - $strategy = new class() implements AccessDecisionStrategyInterface { - public function decide(\Traversable $results): bool - { - $i = 0; - foreach ($results as $result) { - switch ($i++) { - case 0: - Assert::assertSame(VoterInterface::ACCESS_DENIED, $result); - - break; - case 1: - Assert::assertSame(VoterInterface::ACCESS_GRANTED, $result); - - return true; - } - } - return false; + return false; + } + }; + + $manager = new AccessDecisionManager($voters, $strategy); + $accessDecision = null; + + $decision = $manager->decide($token, ['ROLE_FOO'], null, false, $accessDecision); + $this->assertNull($accessDecision); + $this->assertTrue($decision); + } + + public function testVoterCallsWithAccessDecisionObjectFromStrategy() + { + $token = $this->createMock(TokenInterface::class); + + $voters = [ + $this->getExpectedVoter(VoterInterface::ACCESS_DENIED), + $this->getExpectedVoter(VoterInterface::ACCESS_GRANTED), + new DummyVoterWithObject(new Vote(VoterInterface::ACCESS_DENIED)), + $this->getUnexpectedVoter(), + ]; + + $strategy = new class implements AccessDecisionVoteObjectStrategyInterface { + public function decide(\Traversable $results, ?AccessDecision &$accessDecision = null): bool + { + $i = 0; + foreach ($results as $result) { + switch ($i++) { + case 0: + Assert::assertSame(VoterInterface::ACCESS_DENIED, $result); + + break; + case 1: + Assert::assertSame(VoterInterface::ACCESS_GRANTED, $result); + + break; + case 2: + Assert::assertInstanceOf(VoteInterface::class, $result); + Assert::assertSame(VoterInterface::ACCESS_DENIED, $result->getAccess()); + $accessDecision = new AccessDecision(true, [$result], 'message from strategy'); + + return true; + } } - }; - } + + return new AccessDecision(false, [], 'message from strategy'); + } + }; $manager = new AccessDecisionManager($voters, $strategy); - $this->assertEquals($excpectedCallback(true), $manager->$decideFunction($token, ['ROLE_FOO'])); + $accessDecision = null; + $decision = $manager->decide($token, ['ROLE_FOO'], null, false, $accessDecision); + $this->assertInstanceOf(AccessDecision::class, $accessDecision); + $this->assertTrue($decision); + $this->assertTrue($accessDecision->getAccess()); + $this->assertSame('message from strategy', $accessDecision->getMessage()); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testCacheableVoters($useVoteObject, $decideFunction, $voteFunction, $excpectedCallback) + public function testCacheableVoters() { $token = $this->createMock(TokenInterface::class); - $voter = $this->createVoterMock($useVoteObject); + $voter = $this->createMock(CacheableVoterInterface::class); $voter ->expects($this->once()) @@ -145,22 +233,18 @@ public function testCacheableVoters($useVoteObject, $decideFunction, $voteFuncti ->willReturn(true); $voter ->expects($this->once()) - ->method($voteFunction) + ->method('vote') ->with($token, 'bar', ['foo']) - ->willReturn($vote = ($useVoteObject ? new Vote(VoterInterface::ACCESS_GRANTED) : VoterInterface::ACCESS_GRANTED)); + ->willReturn(VoterInterface::ACCESS_GRANTED); $manager = new AccessDecisionManager([$voter]); - $this->assertEquals($excpectedCallback(true, [$vote]), $manager->$decideFunction($token, ['foo'], 'bar')); + $this->assertTrue($manager->decide($token, ['foo'], 'bar')); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testCacheableVotersIgnoresNonStringAttributes($useVoteObject, $decideFunction, $voteFunction, $excpectedCallback) + public function testCacheableVotersIgnoresNonStringAttributes() { $token = $this->createMock(TokenInterface::class); - $voter = $this->createVoterMock($useVoteObject); - + $voter = $this->createMock(CacheableVoterInterface::class); $voter ->expects($this->never()) ->method('supportsAttribute'); @@ -171,22 +255,18 @@ public function testCacheableVotersIgnoresNonStringAttributes($useVoteObject, $d ->willReturn(true); $voter ->expects($this->once()) - ->method($voteFunction) + ->method('vote') ->with($token, 'bar', [1337]) - ->willReturn($vote = ($useVoteObject ? new Vote(VoterInterface::ACCESS_GRANTED) : VoterInterface::ACCESS_GRANTED)); + ->willReturn(VoterInterface::ACCESS_GRANTED); $manager = new AccessDecisionManager([$voter]); - $this->assertEquals($excpectedCallback(true, [$vote]), $manager->$decideFunction($token, [1337], 'bar')); + $this->assertTrue($manager->decide($token, [1337], 'bar')); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testCacheableVotersWithMultipleAttributes($useVoteObject, $decideFunction, $voteFunction, $excpectedCallback) + public function testCacheableVotersWithMultipleAttributes() { $token = $this->createMock(TokenInterface::class); - $voter = $this->createVoterMock($useVoteObject); - + $voter = $this->createMock(CacheableVoterInterface::class); $voter ->expects($this->exactly(2)) ->method('supportsAttribute') @@ -208,22 +288,18 @@ public function testCacheableVotersWithMultipleAttributes($useVoteObject, $decid ->willReturn(true); $voter ->expects($this->once()) - ->method($voteFunction) + ->method('vote') ->with($token, 'bar', ['foo', 'bar']) - ->willReturn($vote = ($useVoteObject ? new Vote(VoterInterface::ACCESS_GRANTED) : VoterInterface::ACCESS_GRANTED)); + ->willReturn(VoterInterface::ACCESS_GRANTED); $manager = new AccessDecisionManager([$voter]); - $this->assertEquals($excpectedCallback(true, [$vote]), $manager->$decideFunction($token, ['foo', 'bar'], 'bar', true)); + $this->assertTrue($manager->decide($token, ['foo', 'bar'], 'bar', true)); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testCacheableVotersWithEmptyAttributes($useVoteObject, $decideFunction, $voteFunction, $excpectedCallback) + public function testCacheableVotersWithEmptyAttributes() { $token = $this->createMock(TokenInterface::class); - $voter = $this->createVoterMock($useVoteObject); - + $voter = $this->createMock(CacheableVoterInterface::class); $voter ->expects($this->never()) ->method('supportsAttribute'); @@ -234,22 +310,18 @@ public function testCacheableVotersWithEmptyAttributes($useVoteObject, $decideFu ->willReturn(true); $voter ->expects($this->once()) - ->method($voteFunction) + ->method('vote') ->with($token, 'bar', []) - ->willReturn($vote = ($useVoteObject ? new Vote(VoterInterface::ACCESS_GRANTED) : VoterInterface::ACCESS_GRANTED)); + ->willReturn(VoterInterface::ACCESS_GRANTED); $manager = new AccessDecisionManager([$voter]); - $this->assertEquals($excpectedCallback(true, [$vote]), $manager->$decideFunction($token, [], 'bar')); + $this->assertTrue($manager->decide($token, [], 'bar')); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testCacheableVotersSupportsMethodsCalledOnce($useVoteObject, $decideFunction, $voteFunction, $excpectedCallback) + public function testCacheableVotersSupportsMethodsCalledOnce() { $token = $this->createMock(TokenInterface::class); - $voter = $this->createVoterMock($useVoteObject); - + $voter = $this->createMock(CacheableVoterInterface::class); $voter ->expects($this->once()) ->method('supportsAttribute') @@ -262,23 +334,19 @@ public function testCacheableVotersSupportsMethodsCalledOnce($useVoteObject, $de ->willReturn(true); $voter ->expects($this->exactly(2)) - ->method($voteFunction) + ->method('vote') ->with($token, 'bar', ['foo']) - ->willReturn($vote = ($useVoteObject ? new Vote(VoterInterface::ACCESS_GRANTED) : VoterInterface::ACCESS_GRANTED)); + ->willReturn(VoterInterface::ACCESS_GRANTED); $manager = new AccessDecisionManager([$voter]); - $this->assertEquals($excpectedCallback(true, [$vote]), $manager->$decideFunction($token, ['foo'], 'bar')); - $this->assertEquals($excpectedCallback(true, [$vote]), $manager->$decideFunction($token, ['foo'], 'bar')); + $this->assertTrue($manager->decide($token, ['foo'], 'bar')); + $this->assertTrue($manager->decide($token, ['foo'], 'bar')); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testCacheableVotersNotCalled($useVoteObject, $decideFunction, $voteFunction, $excpectedCallback) + public function testCacheableVotersNotCalled() { $token = $this->createMock(TokenInterface::class); - $voter = $this->createVoterMock($useVoteObject); - + $voter = $this->createMock(CacheableVoterInterface::class); $voter ->expects($this->once()) ->method('supportsAttribute') @@ -289,20 +357,16 @@ public function testCacheableVotersNotCalled($useVoteObject, $decideFunction, $v ->method('supportsType'); $voter ->expects($this->never()) - ->method($voteFunction); + ->method('vote'); $manager = new AccessDecisionManager([$voter]); - $this->assertEquals($excpectedCallback(false, []), $manager->$decideFunction($token, ['foo'], 'bar')); + $this->assertFalse($manager->decide($token, ['foo'], 'bar')); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testCacheableVotersWithMultipleAttributesAndNonString($useVoteObject, $decideFunction, $voteFunction, $excpectedCallback) + public function testCacheableVotersWithMultipleAttributesAndNonString() { $token = $this->createMock(TokenInterface::class); - $voter = $this->createVoterMock($useVoteObject); - + $voter = $this->createMock(CacheableVoterInterface::class); $voter ->expects($this->once()) ->method('supportsAttribute') @@ -316,12 +380,12 @@ public function testCacheableVotersWithMultipleAttributesAndNonString($useVoteOb ->willReturn(true); $voter ->expects($this->once()) - ->method($voteFunction) + ->method('vote') ->with($token, 'bar', ['foo', 1337]) - ->willReturn($vote = ($useVoteObject ? new Vote(VoterInterface::ACCESS_GRANTED) : VoterInterface::ACCESS_GRANTED)); + ->willReturn(VoterInterface::ACCESS_GRANTED); $manager = new AccessDecisionManager([$voter]); - $this->assertEquals($excpectedCallback(true, [$vote]), $manager->$decideFunction($token, ['foo', 1337], 'bar', true)); + $this->assertTrue($manager->decide($token, ['foo', 1337], 'bar', true)); } protected static function getVoters($grants, $denies, $abstains): array @@ -350,24 +414,19 @@ public function __construct(int $vote) $this->vote = $vote; } - public function vote(TokenInterface $token, $subject, array $attributes): int + public function vote(TokenInterface $token, $subject, array $attributes) { return $this->vote; } - - public function __call($function, $args) - { - throw new LogicException('This function must not be acceded.'); - } }; } - private function getExpectedVoter(int $vote): VoterInterface + private function getExpectedVoter(VoteInterface|int $vote): VoterInterface { $voter = $this->createMock(VoterInterface::class); $voter->expects($this->once()) ->method('vote') - ->willReturn($vote); + ->willReturn($vote instanceof VoteInterface ? $vote->getAccess() : $vote); return $voter; } diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/AuthorizationCheckerTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/AuthorizationCheckerTest.php index f002d203fe7c0..9082eb472bb70 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/AuthorizationCheckerTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/AuthorizationCheckerTest.php @@ -15,58 +15,49 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Security\Core\Authentication\Token\NullToken; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; use Symfony\Component\Security\Core\Authorization\AuthorizationChecker; -use Symfony\Component\Security\Core\Authorization\Voter\CacheableVoterInterface; -use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\User\InMemoryUser; class AuthorizationCheckerTest extends TestCase { + private MockObject&AccessDecisionManagerInterface $accessDecisionManager; + private AuthorizationChecker $authorizationChecker; private TokenStorage $tokenStorage; protected function setUp(): void { + $this->accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class); $this->tokenStorage = new TokenStorage(); + + $this->authorizationChecker = new AuthorizationChecker($this->tokenStorage, $this->accessDecisionManager); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testVoteWithoutAuthenticationToken($useVoteObject, $decideFunction, $voteFunction, $excpectedCallback) + public function testVoteWithoutAuthenticationToken() { - $accessDecisionManager = $this->createAccessDecisionManagerMock($useVoteObject); + $authorizationChecker = new AuthorizationChecker($this->tokenStorage, $this->accessDecisionManager); - $authorizationChecker = new AuthorizationChecker($this->tokenStorage, $accessDecisionManager); - - $accessDecisionManager->expects($this->once()) - ->method($decideFunction) - ->with($this->isInstanceOf(NullToken::class)) - ->willReturn($useVoteObject ? new AccessDecision(VoterInterface::ACCESS_DENIED) : false); + $this->accessDecisionManager->expects($this->once())->method('decide')->with($this->isInstanceOf(NullToken::class))->willReturn(false); $authorizationChecker->isGranted('ROLE_FOO'); } /** - * @dataProvider provideDataWithAndWithoutVoteObject + * @dataProvider isGrantedProvider */ - public function testIsGranted($useVoteObject = null, $decideFunction = null, $voteFunction = null, $excpectedCallback=null) + public function testIsGranted($decide) { - foreach([true, false] as $decision) { - $accessDecisionManager = $this->createAccessDecisionManagerMock($useVoteObject); - $authorizationChecker = new AuthorizationChecker($this->tokenStorage, $accessDecisionManager); - - $token = new UsernamePasswordToken(new InMemoryUser('username', 'password', ['ROLE_USER']), 'provider', ['ROLE_USER']); - - $accessDecisionManager - ->expects($this->once()) - ->method($decideFunction) - ->willReturn($useVoteObject ? new AccessDecision($decision ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED) : $decision); - $this->tokenStorage->setToken($token); - $this->assertSame($decision, $authorizationChecker->isGranted('ROLE_FOO')); - } + $token = new UsernamePasswordToken(new InMemoryUser('username', 'password', ['ROLE_USER']), 'provider', ['ROLE_USER']); + + $this->accessDecisionManager + ->expects($this->once()) + ->method('decide') + ->willReturn($decide); + $this->tokenStorage->setToken($token); + $this->assertSame($decide, $this->authorizationChecker->isGranted('ROLE_FOO')); } public static function isGrantedProvider() @@ -74,54 +65,93 @@ public static function isGrantedProvider() return [[true], [false]]; } - public function provideDataWithAndWithoutVoteObject() + public function testIsGrantedWithObjectAttribute() { - yield [ - 'useVoteObject' => false, - 'decideFunction' => 'decide', - 'voteFunction' => 'vote', - 'excpectedCallback' => fn ($a) => $a, - ]; - - yield [ - 'useVoteObject' => true, - 'decideFunction' => 'getDecision', - 'voteFunction' => 'getVote', - 'excpectedCallback' => fn ($access, $votes = []) => new AccessDecision( - $access ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, - $votes - ), - ]; + $attribute = new \stdClass(); + + $token = new UsernamePasswordToken(new InMemoryUser('username', 'password', ['ROLE_USER']), 'provider', ['ROLE_USER']); + + $this->accessDecisionManager + ->expects($this->once()) + ->method('decide') + ->with($this->identicalTo($token), $this->identicalTo([$attribute])) + ->willReturn(true); + $this->tokenStorage->setToken($token); + $this->assertTrue($this->authorizationChecker->isGranted($attribute)); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testIsGrantedWithObjectAttribute($useVoteObject, $decideFunction, $voteFunction, $excpectedCallback) + public function testIsGrantedWithAccessDecisionObject() { - $accessDecisionManager = $this->createAccessDecisionManagerMock($useVoteObject); + $attribute = new \stdClass(); + + $token = new UsernamePasswordToken(new InMemoryUser('username', 'password', ['ROLE_USER']), 'provider', ['ROLE_USER']); + + $accessDecisionManager = new class implements AccessDecisionManagerInterface { + public function decide(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false, ?AccessDecision &$accessDecision = null): bool + { + $accessDecision = new AccessDecision(true); + + return $accessDecision->getAccess(); + } + }; + $authorizationChecker = new AuthorizationChecker($this->tokenStorage, $accessDecisionManager); + $this->tokenStorage->setToken($token); + + $accessDecision = null; + $decision = $authorizationChecker->isGranted($attribute, $token, $accessDecision); + $this->assertInstanceOf(AccessDecision::class, $accessDecision); + $this->assertTrue($decision); + $this->assertTrue($accessDecision->getAccess()); + $this->assertEmpty($accessDecision->getMessage()); + } + + public function testIsGrantedWithoutAccessDecisionObject() + { $attribute = new \stdClass(); $token = new UsernamePasswordToken(new InMemoryUser('username', 'password', ['ROLE_USER']), 'provider', ['ROLE_USER']); - $accessDecisionManager - ->expects($this->once()) - ->method($decideFunction) - ->with($this->identicalTo($token), $this->identicalTo([$attribute])) - ->willReturn($useVoteObject ? new AccessDecision(VoterInterface::ACCESS_GRANTED) : true); + $accessDecisionManager = new class implements AccessDecisionManagerInterface { + public function decide(TokenInterface $token, array $attributes, mixed $object = null): bool + { + return true; + } + }; + + $authorizationChecker = new AuthorizationChecker($this->tokenStorage, $accessDecisionManager); + $this->tokenStorage->setToken($token); - $this->assertTrue($authorizationChecker->isGranted($attribute)); + + $accessDecision = null; + $decision = $authorizationChecker->isGranted($attribute, $token, $accessDecision); + $this->assertNull($accessDecision); + $this->assertTrue($decision); } - public function createAccessDecisionManagerMock(bool $useVoteObject) + public function testIsGrantedWithAccessDecisionObjectFromADM() { - return $useVoteObject ? - $this->getMockBuilder(AccessDecisionManagerInterface::class) - ->onlyMethods(['decide']) - ->addMethods(['getDecision']) - ->getMock(): - $this->createMock(AccessDecisionManagerInterface::class); + $attribute = new \stdClass(); + + $token = new UsernamePasswordToken(new InMemoryUser('username', 'password', ['ROLE_USER']), 'provider', ['ROLE_USER']); + + $accessDecisionManager = new class implements AccessDecisionManagerInterface { + public function decide(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false, ?AccessDecision &$accessDecision = null): bool + { + $accessDecision = new AccessDecision(true, [], 'from accessDecisionManager'); + + return $accessDecision->getAccess(); + } + }; + + $authorizationChecker = new AuthorizationChecker($this->tokenStorage, $accessDecisionManager); + $this->tokenStorage->setToken($token); + + $accessDecision = null; + $decision = $authorizationChecker->isGranted($attribute, $token, $accessDecision); + $this->assertTrue($decision); + $this->assertTrue($accessDecision->getAccess()); + $this->assertSame('from accessDecisionManager', $accessDecision->getMessage()); } } diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/AffirmativeStrategyTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/AffirmativeStrategyTest.php index d9bce53f71513..21e45482331c3 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/AffirmativeStrategyTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/AffirmativeStrategyTest.php @@ -12,8 +12,8 @@ namespace Authorization\Strategy; use Symfony\Component\Security\Core\Authorization\Strategy\AffirmativeStrategy; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Test\AccessDecisionStrategyTestCase; -use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; class AffirmativeStrategyTest extends AccessDecisionStrategyTestCase { @@ -21,26 +21,56 @@ public static function provideStrategyTests(): iterable { $strategy = new AffirmativeStrategy(); - yield [$strategy, self::getVoters(1, 0, 0), self::getAccessDecision(true, [ - VoterInterface::ACCESS_GRANTED, - ])]; - yield [$strategy, self::getVoters(1, 2, 0), self::getAccessDecision(true, [ - VoterInterface::ACCESS_GRANTED, - ])]; - yield [$strategy, self::getVoters(0, 1, 0), self::getAccessDecision(false, [ - VoterInterface::ACCESS_DENIED, - VoterInterface::ACCESS_DENIED, - ])]; - yield [$strategy, self::getVoters(0, 0, 1), self::getAccessDecision(false, [ - VoterInterface::ACCESS_ABSTAIN, - VoterInterface::ACCESS_ABSTAIN, - ])]; + yield [$strategy, self::getVoters(1, 0, 0), true, [1]]; + yield [$strategy, self::getVoters(1, 2, 0), true, [1]]; + yield [$strategy, self::getVoters(0, 1, 0), false, [-1]]; + yield [$strategy, self::getVoters(0, 0, 1), false, [0]]; + + yield [$strategy, [ + self::getVoterWithVoteObject(0), + self::getVoter(-1), + self::getVoter(0), + self::getVoterWithVoteObject(1), + ], true, [ + new Vote(0), + -1, + 0, + new Vote(1), + ]]; + + yield [$strategy, [ + self::getVoterWithVoteObject(0), + self::getVoter(-1), + self::getVoter(0), + self::getVoterWithVoteObject(0), + ], false, [ + new Vote(0), + -1, + 0, + new Vote(0), + ]]; + + yield [$strategy, [ + self::getVoterWithVoteObject(0), + self::getVoter(1), + self::getVoter(0), + self::getVoterWithVoteObject(-1), + ], true, [ + new Vote(0), + 1, + ]]; + + yield [$strategy, [ + self::getVoterWithVoteObject(1), + self::getVoter(-1), + self::getVoter(0), + self::getVoterWithVoteObject(-1), + ], true, [ + new Vote(1), + ]]; $strategy = new AffirmativeStrategy(true); - yield [$strategy, self::getVoters(0, 0, 1), self::getAccessDecision(true, [ - VoterInterface::ACCESS_ABSTAIN, - VoterInterface::ACCESS_ABSTAIN, - ])]; + yield [$strategy, self::getVoters(0, 0, 1), true, [0]]; } } diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ConsensusStrategyTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ConsensusStrategyTest.php index 6db0c77d74a2d..975b404ff8fa8 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ConsensusStrategyTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ConsensusStrategyTest.php @@ -12,7 +12,7 @@ namespace Authorization\Strategy; use Symfony\Component\Security\Core\Authorization\Strategy\ConsensusStrategy; -use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Test\AccessDecisionStrategyTestCase; class ConsensusStrategyTest extends AccessDecisionStrategyTestCase @@ -21,89 +21,57 @@ public static function provideStrategyTests(): iterable { $strategy = new ConsensusStrategy(); - yield [$strategy, self::getVoters(1, 0, 0), self::getAccessDecision(true, [ - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_GRANTED, - ])]; + yield [$strategy, self::getVoters(1, 0, 0), true, [1]]; + yield [$strategy, self::getVoters(1, 2, 0), false, [1, -1, -1]]; + yield [$strategy, self::getVoters(2, 1, 0), true, [1, 1, -1]]; + yield [$strategy, self::getVoters(0, 0, 1), false, [0]]; - yield [$strategy, self::getVoters(1, 2, 0), self::getAccessDecision(false, [ - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_DENIED, - VoterInterface::ACCESS_DENIED, - VoterInterface::ACCESS_DENIED, - VoterInterface::ACCESS_DENIED, - ])]; - - yield [$strategy, self::getVoters(2, 1, 0), self::getAccessDecision(true, [ - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_DENIED, - VoterInterface::ACCESS_DENIED, - ])]; - - yield [$strategy, self::getVoters(0, 0, 1), self::getAccessDecision(false, [ - VoterInterface::ACCESS_ABSTAIN, - VoterInterface::ACCESS_ABSTAIN, - ])]; - - yield [$strategy, self::getVoters(2, 2, 0), self::getAccessDecision(true, [ - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_DENIED, - VoterInterface::ACCESS_DENIED, - VoterInterface::ACCESS_DENIED, - VoterInterface::ACCESS_DENIED, - ])]; - - yield [$strategy, self::getVoters(2, 2, 1), self::getAccessDecision(true, [ - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_DENIED, - VoterInterface::ACCESS_DENIED, - VoterInterface::ACCESS_DENIED, - VoterInterface::ACCESS_DENIED, - VoterInterface::ACCESS_ABSTAIN, - VoterInterface::ACCESS_ABSTAIN, - ])]; + yield [$strategy, self::getVoters(2, 2, 0), true, [1, 1, -1, -1]]; + yield [$strategy, self::getVoters(2, 2, 1), true, [1, 1, -1, -1, 0]]; $strategy = new ConsensusStrategy(true); - yield [$strategy, self::getVoters(0, 0, 1), self::getAccessDecision(true, [ - VoterInterface::ACCESS_ABSTAIN, - VoterInterface::ACCESS_ABSTAIN, - ])]; + yield [$strategy, self::getVoters(0, 0, 1), true, [0]]; $strategy = new ConsensusStrategy(false, false); - yield [$strategy, self::getVoters(2, 2, 0), self::getAccessDecision(false, [ - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_DENIED, - VoterInterface::ACCESS_DENIED, - VoterInterface::ACCESS_DENIED, - VoterInterface::ACCESS_DENIED, - ])]; + yield [$strategy, self::getVoters(2, 2, 0), false, [1, 1, -1, -1]]; + yield [$strategy, self::getVoters(2, 2, 1), false, [1, 1, -1, -1, 0]]; + + yield [$strategy, [ + self::getVoterWithVoteObject(1), + self::getVoter(-1), + self::getVoter(0), + self::getVoterWithVoteObject(1), + ], true, [ + new Vote(1), + -1, + 0, + new Vote(1), + ]]; + + yield [$strategy, [ + self::getVoterWithVoteObject(1), + self::getVoter(-1), + self::getVoter(-1), + self::getVoterWithVoteObject(1), + ], false, [ + new Vote(1), + -1, + -1, + new Vote(1), + ]]; - yield [$strategy, self::getVoters(2, 2, 1), self::getAccessDecision(false, [ - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_DENIED, - VoterInterface::ACCESS_DENIED, - VoterInterface::ACCESS_DENIED, - VoterInterface::ACCESS_DENIED, - VoterInterface::ACCESS_ABSTAIN, - VoterInterface::ACCESS_ABSTAIN, - ])]; + yield [$strategy, [ + self::getVoterWithVoteObject(1), + self::getVoter(-1), + self::getVoter(-1), + self::getVoterWithVoteObject(0), + ], false, [ + new Vote(1), + -1, + -1, + new Vote(0), + ]]; } } diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/PriorityStrategyTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/PriorityStrategyTest.php index 43e3e724ffa07..0708b5f543cd6 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/PriorityStrategyTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/PriorityStrategyTest.php @@ -12,6 +12,7 @@ namespace Authorization\Strategy; use Symfony\Component\Security\Core\Authorization\Strategy\PriorityStrategy; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Test\AccessDecisionStrategyTestCase; @@ -26,35 +27,40 @@ public static function provideStrategyTests(): iterable self::getVoter(VoterInterface::ACCESS_GRANTED), self::getVoter(VoterInterface::ACCESS_DENIED), self::getVoter(VoterInterface::ACCESS_DENIED), - ], self::getAccessDecision(true, [ - VoterInterface::ACCESS_ABSTAIN, - VoterInterface::ACCESS_GRANTED, - ])]; + ], true, [0, 1]]; yield [$strategy, [ self::getVoter(VoterInterface::ACCESS_ABSTAIN), self::getVoter(VoterInterface::ACCESS_DENIED), self::getVoter(VoterInterface::ACCESS_GRANTED), self::getVoter(VoterInterface::ACCESS_GRANTED), - ], self::getAccessDecision(false, [ - VoterInterface::ACCESS_ABSTAIN, - VoterInterface::ACCESS_DENIED, - ])]; - - yield [$strategy, self::getVoters(0, 0, 2), self::getAccessDecision(false, [ - VoterInterface::ACCESS_ABSTAIN, - VoterInterface::ACCESS_ABSTAIN, - VoterInterface::ACCESS_ABSTAIN, - VoterInterface::ACCESS_ABSTAIN - ])]; + ], false, [0, -1]]; + + yield [$strategy, [ + self::getVoterWithVoteObject(1), + self::getVoter(-1), + self::getVoter(0), + self::getVoterWithVoteObject(1), + ], true, [new Vote(1)]]; + + yield [$strategy, [ + self::getVoterWithVoteObject(0), + self::getVoter(-1), + self::getVoter(0), + self::getVoterWithVoteObject(1), + ], false, [new Vote(0), -1]]; + + yield [$strategy, [ + self::getVoterWithVoteObject(-1), + self::getVoter(1), + self::getVoter(0), + self::getVoterWithVoteObject(1), + ], false, [new Vote(-1)]]; + + yield [$strategy, self::getVoters(0, 0, 2), false, [0, 0]]; $strategy = new PriorityStrategy(true); - yield [$strategy, self::getVoters(0, 0, 2), self::getAccessDecision(true, [ - VoterInterface::ACCESS_ABSTAIN, - VoterInterface::ACCESS_ABSTAIN, - VoterInterface::ACCESS_ABSTAIN, - VoterInterface::ACCESS_ABSTAIN, - ])]; + yield [$strategy, self::getVoters(0, 0, 2), true, [0, 0]]; } } diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ScoringStrategyTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ScoringStrategyTest.php deleted file mode 100644 index 2f269abc4b4e1..0000000000000 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ScoringStrategyTest.php +++ /dev/null @@ -1,168 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Authorization\Strategy; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\Authorization\AccessDecision; -use Symfony\Component\Security\Core\Authorization\AccessDecisionManager; -use Symfony\Component\Security\Core\Authorization\Strategy\AccessDecisionStrategyInterface; -use Symfony\Component\Security\Core\Authorization\Strategy\ScoringStrategy; -use Symfony\Component\Security\Core\Authorization\Voter\Vote; -use Symfony\Component\Security\Core\Authorization\Voter\Voter; -use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; -use Symfony\Component\Security\Core\Exception\LogicException; - -class ScoringStrategyTest extends TestCase -{ - /** - * @dataProvider provideStrategyTests - * - * @param VoterInterface[] $voters - */ - public function testGetDecision(AccessDecisionStrategyInterface $strategy, array $voters, AccessDecision $expected) - { - $token = $this->createMock(TokenInterface::class); - $manager = new AccessDecisionManager($voters, $strategy); - - $this->assertEquals($expected, $manager->getDecision($token, ['ROLE_FOO'])); - } - - public static function provideStrategyTests(): iterable - { - $strategy = new ScoringStrategy(); - - yield [$strategy, [ - self::getVoter(VoterInterface::ACCESS_DENIED), - self::getVoter(VoterInterface::ACCESS_ABSTAIN), - ], self::getAccessDecision(false, [ - [VoterInterface::ACCESS_DENIED, false], - [VoterInterface::ACCESS_ABSTAIN, false], - ], 'score = -1' - )]; - - yield [$strategy, [ - self::getVoterWithVoteObject(VoterInterface::ACCESS_DENIED), - self::getVoter(VoterInterface::ACCESS_ABSTAIN), - ], self::getAccessDecision(false, [ - [VoterInterface::ACCESS_DENIED, false], - [VoterInterface::ACCESS_ABSTAIN, false], - ], 'score = -1' - )]; - - yield [$strategy, [ - self::getVoterWithVoteObjectAndScoring(5), - ], self::getAccessDecision(true, [ - [5, true], - ], 'score = 5' - )]; - - yield [$strategy, [ - self::getVoter(VoterInterface::ACCESS_DENIED), - self::getVoter(VoterInterface::ACCESS_DENIED), - self::getVoter(VoterInterface::ACCESS_DENIED), - self::getVoterWithVoteObjectAndScoring(VoterInterface::ACCESS_ABSTAIN), - self::getVoterWithVoteObjectAndScoring(2), - ], self::getAccessDecision(false, [ - [VoterInterface::ACCESS_DENIED, false], - [VoterInterface::ACCESS_DENIED, false], - [VoterInterface::ACCESS_DENIED, false], - [VoterInterface::ACCESS_ABSTAIN, true], - [2, true], - ], 'score = -1' - )]; - - yield [$strategy, [ - self::getVoter(VoterInterface::ACCESS_DENIED), - self::getVoter(VoterInterface::ACCESS_DENIED), - self::getVoter(VoterInterface::ACCESS_DENIED), - self::getVoter(VoterInterface::ACCESS_DENIED), - self::getVoterWithVoteObjectAndScoring(5), - ], self::getAccessDecision(true, [ - [VoterInterface::ACCESS_DENIED, false], - [VoterInterface::ACCESS_DENIED, false], - [VoterInterface::ACCESS_DENIED, false], - [VoterInterface::ACCESS_DENIED, false], - [5, true], - ], 'score = 1' - )]; - } - - - protected static function getVoter(int $vote): VoterInterface - { - return new class($vote) implements VoterInterface { - public function __construct( - private int $vote, - ) { - } - - public function vote(TokenInterface $token, $subject, array $attributes): int - { - return $this->vote; - } - - public function __call($function, $args) - { - throw new LogicException('This function must not be acceded.'); - } - }; - } - - protected static function getVoterWithVoteObject(int $vote): VoterInterface - { - return new class($vote) implements VoterInterface { - public function __construct( - private int $vote, - ) { - } - - public function vote(TokenInterface $token, $subject, array $attributes): int - { - return $this->vote; - } - - public function getVote(TokenInterface $token, mixed $subject, array $attributes): Vote - { - return new Vote($this->vote); - } - }; - } - - protected static function getVoterWithVoteObjectAndScoring(int $vote): VoterInterface - { - return new class($vote) implements VoterInterface { - public function __construct( - private int $vote, - ) { - } - - public function vote(TokenInterface $token, $subject, array $attributes): int - { - return $this->vote; - } - - public function getVote(TokenInterface $token, mixed $subject, array $attributes): Vote - { - return new Vote($this->vote, scoring: true); - } - }; - } - - protected static function getAccessDecision(bool $decision, array $votes, string $message): AccessDecision - { - return new AccessDecision($decision ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, - array_map(fn ($vote) => new Vote($vote[0], scoring: $vote[1]), $votes), - $message - ); - } -} diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/UnanimousStrategyTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/UnanimousStrategyTest.php index 52c28188cced4..74b0882449435 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/UnanimousStrategyTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/UnanimousStrategyTest.php @@ -12,7 +12,7 @@ namespace Authorization\Strategy; use Symfony\Component\Security\Core\Authorization\Strategy\UnanimousStrategy; -use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Test\AccessDecisionStrategyTestCase; class UnanimousStrategyTest extends AccessDecisionStrategyTestCase @@ -21,36 +21,40 @@ public static function provideStrategyTests(): iterable { $strategy = new UnanimousStrategy(); - yield [$strategy, self::getVoters(1, 0, 0), self::getAccessDecision(true, [ - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_GRANTED, - ])]; - yield [$strategy, self::getVoters(1, 0, 1), self::getAccessDecision(true, [ - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_ABSTAIN, - VoterInterface::ACCESS_ABSTAIN, - ])]; - yield [$strategy, self::getVoters(1, 1, 0), self::getAccessDecision(false, [ - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_GRANTED, - VoterInterface::ACCESS_DENIED, - ])]; - - yield [$strategy, self::getVoters(0, 0, 2), self::getAccessDecision(false, [ - VoterInterface::ACCESS_ABSTAIN, - VoterInterface::ACCESS_ABSTAIN, - VoterInterface::ACCESS_ABSTAIN, - VoterInterface::ACCESS_ABSTAIN, - ])]; - - $strategy = new UnanimousStrategy(true); - - yield [$strategy, self::getVoters(0, 0, 2), self::getAccessDecision(true, [ - VoterInterface::ACCESS_ABSTAIN, - VoterInterface::ACCESS_ABSTAIN, - VoterInterface::ACCESS_ABSTAIN, - VoterInterface::ACCESS_ABSTAIN, - ])]; + yield [$strategy, self::getVoters(1, 0, 0), true, [1]]; + yield [$strategy, self::getVoters(1, 0, 1), true, [1, 0]]; + yield [$strategy, self::getVoters(1, 1, 0), false, [1, -1]]; + + yield [$strategy, self::getVoters(0, 0, 2), false, [0, 0]]; + + $strategy = new UnanimousStrategy(true, []); + + yield [$strategy, self::getVoters(0, 0, 2), true, [0, 0]]; + + yield [$strategy, [ + self::getVoterWithVoteObject(1), + self::getVoter(-1), + self::getVoter(0), + self::getVoterWithVoteObject(1), + ], false, [new Vote(1), -1]]; + + yield [$strategy, [ + self::getVoterWithVoteObject(-1), + self::getVoter(1), + self::getVoter(0), + self::getVoterWithVoteObject(1), + ], false, [new Vote(-1)]]; + + yield [$strategy, [ + self::getVoterWithVoteObject(0), + self::getVoter(0), + self::getVoter(0), + self::getVoterWithVoteObject(1), + ], true, [ + new Vote(0), + 0, + 0, + new Vote(1) + ]]; } } diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerTest.php index c3765a4ddc15c..515393caf028d 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerTest.php @@ -19,6 +19,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Tests\Fixtures\DummyVoter; +use Symfony\Component\Security\Core\Tests\Fixtures\DummyVoterWithObject; class TraceableAccessDecisionManagerTest extends TestCase { @@ -55,17 +56,18 @@ public static function provideObjectsAndLogs(): \Generator { $voter1 = new DummyVoter(); $voter2 = new DummyVoter(); + $voter3 = new DummyVoterWithObject(); yield [ [[ - 'attributes' => ['ATTRIBUTE_1'], - 'object' => null, - 'result' => true, - 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1'], 'vote' => VoterInterface::ACCESS_GRANTED], - ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1'], 'vote' => VoterInterface::ACCESS_GRANTED], - ], - ]], + 'attributes' => ['ATTRIBUTE_1'], + 'object' => null, + 'result' => true, + 'voterDetails' => [ + ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1'], 'vote' => VoterInterface::ACCESS_GRANTED], + ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1'], 'vote' => VoterInterface::ACCESS_GRANTED], + ], + ]], ['ATTRIBUTE_1'], null, [ @@ -76,32 +78,14 @@ public static function provideObjectsAndLogs(): \Generator ]; yield [ [[ - 'attributes' => ['ATTRIBUTE_1'], - 'object' => null, - 'result' => true, - 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1'], 'vote' => VoterInterface::ACCESS_GRANTED], - ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], - ], - ]], - ['ATTRIBUTE_1'], - null, - [ - [$voter1, VoterInterface::ACCESS_GRANTED], - [$voter2, new Vote(VoterInterface::ACCESS_GRANTED)], - ], - true, - ]; - yield [ - [[ - 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], - 'object' => true, - 'result' => false, - 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_ABSTAIN], - ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_GRANTED], - ], - ]], + 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], + 'object' => true, + 'result' => false, + 'voterDetails' => [ + ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_ABSTAIN], + ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_GRANTED], + ], + ]], ['ATTRIBUTE_1', 'ATTRIBUTE_2'], true, [ @@ -112,32 +96,14 @@ public static function provideObjectsAndLogs(): \Generator ]; yield [ [[ - 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], - 'object' => true, - 'result' => false, - 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], - ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], - ], - ]], - ['ATTRIBUTE_1', 'ATTRIBUTE_2'], - true, - [ - [$voter1, new Vote(VoterInterface::ACCESS_ABSTAIN)], - [$voter2, new Vote(VoterInterface::ACCESS_GRANTED)], - ], - false, - ]; - yield [ - [[ - 'attributes' => [null], - 'object' => 'jolie string', - 'result' => false, - 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => [null], 'vote' => VoterInterface::ACCESS_ABSTAIN], - ['voter' => $voter2, 'attributes' => [null], 'vote' => VoterInterface::ACCESS_DENIED], - ], - ]], + 'attributes' => [null], + 'object' => 'jolie string', + 'result' => false, + 'voterDetails' => [ + ['voter' => $voter1, 'attributes' => [null], 'vote' => VoterInterface::ACCESS_ABSTAIN], + ['voter' => $voter2, 'attributes' => [null], 'vote' => VoterInterface::ACCESS_DENIED], + ], + ]], [null], 'jolie string', [ @@ -146,24 +112,6 @@ public static function provideObjectsAndLogs(): \Generator ], false, ]; - yield [ - [[ - 'attributes' => [null], - 'object' => 'jolie string', - 'result' => false, - 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => [null], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], - ['voter' => $voter2, 'attributes' => [null], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], - ], - ]], - [null], - 'jolie string', - [ - [$voter1, new Vote(VoterInterface::ACCESS_ABSTAIN)], - [$voter2, new Vote(VoterInterface::ACCESS_DENIED)], - ], - false, - ]; yield [ [[ 'attributes' => [12], @@ -224,6 +172,27 @@ public static function provideObjectsAndLogs(): \Generator ], false, ]; + + yield [ + [[ + 'attributes' => ['ATTRIBUTE_1'], + 'object' => null, + 'result' => true, + 'voterDetails' => [ + ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1'], 'vote' => VoterInterface::ACCESS_GRANTED], + ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1'], 'vote' => VoterInterface::ACCESS_GRANTED], + ['voter' => $voter3, 'attributes' => ['ATTRIBUTE_1'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], + ], + ]], + ['ATTRIBUTE_1'], + null, + [ + [$voter1, VoterInterface::ACCESS_GRANTED], + [$voter2, VoterInterface::ACCESS_GRANTED], + [$voter3, new Vote(VoterInterface::ACCESS_GRANTED)], + ], + true, + ]; } /** @@ -298,8 +267,6 @@ public function testAccessDecisionManagerCalledByVoter() 'object' => null, 'voterDetails' => [ ['voter' => $voter1, 'attributes' => ['attr1'], 'vote' => VoterInterface::ACCESS_GRANTED], - ['voter' => $voter2, 'attributes' => ['attr1'], 'vote' => VoterInterface::ACCESS_ABSTAIN], - ['voter' => $voter3, 'attributes' => ['attr1'], 'vote' => VoterInterface::ACCESS_ABSTAIN], ], 'result' => true, ], @@ -309,7 +276,6 @@ public function testAccessDecisionManagerCalledByVoter() 'voterDetails' => [ ['voter' => $voter1, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_ABSTAIN], ['voter' => $voter2, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_GRANTED], - ['voter' => $voter3, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_ABSTAIN], ], 'result' => true, ], diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerWithVoteObjectTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerWithVoteObjectTest.php deleted file mode 100644 index c8de8c9fed452..0000000000000 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerWithVoteObjectTest.php +++ /dev/null @@ -1,295 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Core\Tests\Authorization; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\Authorization\AccessDecision; -use Symfony\Component\Security\Core\Authorization\AccessDecisionManager; -use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; -use Symfony\Component\Security\Core\Authorization\TraceableAccessDecisionManager; -use Symfony\Component\Security\Core\Authorization\Voter\Vote; -use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; -use Symfony\Component\Security\Core\Tests\Fixtures\DummyVoter; - -class TraceableAccessDecisionManagerWithVoteObjectTest extends TestCase -{ - /** - * @dataProvider provideObjectsAndLogs - */ - public function testDecideLog(array $expectedLog, array $attributes, $object, array $voterVotes, AccessDecision $decision) - { - $token = $this->createMock(TokenInterface::class); - $admMock = $this - ->getMockBuilder(AccessDecisionManagerInterface::class) - ->onlyMethods(['decide']) - ->addMethods(['getDecision']) - ->getMock(); - - $adm = new TraceableAccessDecisionManager($admMock); - - $admMock - ->expects($this->once()) - ->method('getDecision') - ->with($token, $attributes, $object) - ->willReturnCallback(function ($token, $attributes, $object) use ($voterVotes, $adm, $decision) { - foreach ($voterVotes as $voterVote) { - [$voter, $vote] = $voterVote; - $adm->addVoterVote($voter, $attributes, $vote); - } - - return $decision; - }) - ; - - $adm->getDecision($token, $attributes, $object); - - $this->assertEquals($expectedLog, $adm->getDecisionLog()); - } - - public static function provideObjectsAndLogs(): \Generator - { - $voter1 = new DummyVoter(); - $voter2 = new DummyVoter(); - - yield [ - [[ - 'attributes' => ['ATTRIBUTE_1'], - 'object' => null, - 'result' => $result = new AccessDecision(VoterInterface::ACCESS_GRANTED), - 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], - ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], - ], - ]], - ['ATTRIBUTE_1'], - null, - [ - [$voter1, new Vote(VoterInterface::ACCESS_GRANTED)], - [$voter2, new Vote(VoterInterface::ACCESS_GRANTED)], - ], - $result, - ]; - yield [ - [[ - 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], - 'object' => true, - 'result' => $result = new AccessDecision(VoterInterface::ACCESS_DENIED), - 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_ABSTAIN], - ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], - ], - ]], - ['ATTRIBUTE_1', 'ATTRIBUTE_2'], - true, - [ - [$voter1, VoterInterface::ACCESS_ABSTAIN], - [$voter2, new Vote(VoterInterface::ACCESS_GRANTED)], - ], - $result, - ]; - yield [ - [[ - 'attributes' => [null], - 'object' => 'jolie string', - 'result' => $result = new AccessDecision(VoterInterface::ACCESS_DENIED), - 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => [null], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], - ['voter' => $voter2, 'attributes' => [null], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], - ], - ]], - [null], - 'jolie string', - [ - [$voter1, new Vote(VoterInterface::ACCESS_ABSTAIN)], - [$voter2, new Vote(VoterInterface::ACCESS_DENIED)], - ], - $result, - ]; - yield [ - [[ - 'attributes' => [12], - 'object' => 12345, - 'result' => $result = new AccessDecision(VoterInterface::ACCESS_GRANTED), - 'voterDetails' => [], - ]], - 'attributes' => [12], - 12345, - [], - $result, - ]; - yield [ - [[ - 'attributes' => [new \stdClass()], - 'object' => $x = fopen(__FILE__, 'r'), - 'result' => $result = new AccessDecision(VoterInterface::ACCESS_GRANTED), - 'voterDetails' => [], - ]], - [new \stdClass()], - $x, - [], - $result, - ]; - yield [ - [[ - 'attributes' => ['ATTRIBUTE_2'], - 'object' => $x = [], - 'result' => $result = new AccessDecision(VoterInterface::ACCESS_DENIED), - 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], - ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], - ], - ]], - ['ATTRIBUTE_2'], - $x, - [ - [$voter1, new Vote(VoterInterface::ACCESS_ABSTAIN)], - [$voter2, new Vote(VoterInterface::ACCESS_ABSTAIN)], - ], - $result, - ]; - yield [ - [[ - 'attributes' => [12.13], - 'object' => new \stdClass(), - 'result' => $result = new AccessDecision(VoterInterface::ACCESS_DENIED), - 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => [12.13], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], - ['voter' => $voter2, 'attributes' => [12.13], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], - ], - ]], - [12.13], - new \stdClass(), - [ - [$voter1, new Vote(VoterInterface::ACCESS_DENIED)], - [$voter2, new Vote(VoterInterface::ACCESS_DENIED)], - ], - $result, - ]; - } - - /** - * Tests decision log returned when a voter call decide method of AccessDecisionManager. - */ - public function testAccessDecisionManagerCalledByVoter() - { - $voter1 = $this - ->getMockBuilder(VoterInterface::class) - ->onlyMethods(['vote']) - ->addMethods(['getVote']) - ->getMock(); - - $voter2 = $this - ->getMockBuilder(VoterInterface::class) - ->onlyMethods(['vote']) - ->addMethods(['getVote']) - ->getMock(); - - $voter3 = $this - ->getMockBuilder(VoterInterface::class) - ->onlyMethods(['vote']) - ->addMethods(['getVote']) - ->getMock(); - - $sut = new TraceableAccessDecisionManager(new AccessDecisionManager([$voter1, $voter2, $voter3])); - - $voter1 - ->expects($this->any()) - ->method('getVote') - ->willReturnCallback(function (TokenInterface $token, $subject, array $attributes) use ($sut, $voter1) { - $vote = new Vote(\in_array('attr1', $attributes, true) ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_ABSTAIN); - $sut->addVoterVote($voter1, $attributes, $vote); - - return $vote; - }); - - $voter2 - ->expects($this->any()) - ->method('getVote') - ->willReturnCallback(function (TokenInterface $token, $subject, array $attributes) use ($sut, $voter2) { - if (\in_array('attr2', $attributes, true)) { - $vote = new Vote((null == $subject) ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED); - } else { - $vote = new Vote(VoterInterface::ACCESS_ABSTAIN); - } - - $sut->addVoterVote($voter2, $attributes, $vote); - - return $vote; - }); - - $voter3 - ->expects($this->any()) - ->method('getVote') - ->willReturnCallback(function (TokenInterface $token, $subject, array $attributes) use ($sut, $voter3) { - if (\in_array('attr2', $attributes, true) && $subject) { - $vote = new Vote($sut->getDecision($token, $attributes)->isGranted() ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED); - } else { - $vote = new Vote(VoterInterface::ACCESS_ABSTAIN); - } - - $sut->addVoterVote($voter3, $attributes, $vote); - - return $vote; - }); - - $token = $this->createMock(TokenInterface::class); - $sut->getDecision($token, ['attr1'], null); - $sut->getDecision($token, ['attr2'], $obj = new \stdClass()); - - $this->assertEquals([ - [ - 'attributes' => ['attr1'], - 'object' => null, - 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['attr1'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], - ], - 'result' => new AccessDecision(VoterInterface::ACCESS_GRANTED, [new Vote(VoterInterface::ACCESS_GRANTED)]), - ], - [ - 'attributes' => ['attr2'], - 'object' => null, - 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], - ['voter' => $voter2, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], - ], - 'result' => new AccessDecision(VoterInterface::ACCESS_GRANTED, [ - new Vote(VoterInterface::ACCESS_ABSTAIN), - new Vote(VoterInterface::ACCESS_GRANTED), - ]), - ], - [ - 'attributes' => ['attr2'], - 'object' => $obj, - 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_ABSTAIN)], - ['voter' => $voter2, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_DENIED)], - ['voter' => $voter3, 'attributes' => ['attr2'], 'vote' => new Vote(VoterInterface::ACCESS_GRANTED)], - ], - 'result' => new AccessDecision(VoterInterface::ACCESS_GRANTED, [ - new Vote(VoterInterface::ACCESS_ABSTAIN), - new Vote(VoterInterface::ACCESS_DENIED), - new Vote(VoterInterface::ACCESS_GRANTED), - ]), - ], - ], $sut->getDecisionLog()); - } - - public function testCustomAccessDecisionManagerReturnsEmptyStrategy() - { - $admMock = $this->createMock(AccessDecisionManagerInterface::class); - - $adm = new TraceableAccessDecisionManager($admMock); - - $this->assertEquals('-', $adm->getStrategy()); - } -} diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/AuthenticatedVoterTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/AuthenticatedVoterTest.php index 830c8baa06880..89f6c35007520 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/AuthenticatedVoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/AuthenticatedVoterTest.php @@ -35,16 +35,6 @@ public function testVote($authenticated, $attributes, $expected) $this->assertSame($expected, $voter->vote($this->getToken($authenticated), null, $attributes)); } - /** - * @dataProvider getVoteTests - */ - public function testGetVote($authenticated, $attributes, $expected) - { - $voter = new AuthenticatedVoter(new AuthenticationTrustResolver()); - - $this->assertSame($expected, $voter->getVote($this->getToken($authenticated), null, $attributes)->getAccess()); - } - public static function getVoteTests() { return [ diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/ExpressionVoterTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/ExpressionVoterTest.php index a1b3d43309124..369b17f0460ea 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/ExpressionVoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/ExpressionVoterTest.php @@ -32,16 +32,6 @@ public function testVoteWithTokenThatReturnsRoleNames($roles, $attributes, $expe $this->assertSame($expected, $voter->vote($this->getTokenWithRoleNames($roles, $tokenExpectsGetRoles), null, $attributes)); } - /** - * @dataProvider getVoteTests - */ - public function testGetVoteWithTokenThatReturnsRoleNames($roles, $attributes, $expected, $tokenExpectsGetRoles = true, $expressionLanguageExpectsEvaluate = true) - { - $voter = new ExpressionVoter($this->createExpressionLanguage($expressionLanguageExpectsEvaluate), $this->createTrustResolver(), $this->createAuthorizationChecker()); - - $this->assertSame($expected, $voter->getVote($this->getTokenWithRoleNames($roles, $tokenExpectsGetRoles), null, $attributes)->getAccess()); - } - public static function getVoteTests() { return [ diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleHierarchyVoterTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleHierarchyVoterTest.php index e0a4e61ae3d61..b811bd745bb85 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleHierarchyVoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleHierarchyVoterTest.php @@ -27,16 +27,6 @@ public function testVoteUsingTokenThatReturnsRoleNames($roles, $attributes, $exp $this->assertSame($expected, $voter->vote($this->getTokenWithRoleNames($roles), null, $attributes)); } - /** - * @dataProvider getVoteTests - */ - public function testGetVoteUsingTokenThatReturnsRoleNames($roles, $attributes, $expected) - { - $voter = new RoleHierarchyVoter(new RoleHierarchy(['ROLE_FOO' => ['ROLE_FOOBAR']])); - - $this->assertSame($expected, $voter->getVote($this->getTokenWithRoleNames($roles), null, $attributes)->getAccess()); - } - public static function getVoteTests() { return array_merge(parent::getVoteTests(), [ @@ -54,16 +44,6 @@ public function testVoteWithEmptyHierarchyUsingTokenThatReturnsRoleNames($roles, $this->assertSame($expected, $voter->vote($this->getTokenWithRoleNames($roles), null, $attributes)); } - /** - * @dataProvider getVoteWithEmptyHierarchyTests - */ - public function testGetVoteWithEmptyHierarchyUsingTokenThatReturnsRoleNames($roles, $attributes, $expected) - { - $voter = new RoleHierarchyVoter(new RoleHierarchy([])); - - $this->assertSame($expected, $voter->getVote($this->getTokenWithRoleNames($roles), null, $attributes)->getAccess()); - } - public static function getVoteWithEmptyHierarchyTests() { return parent::getVoteTests(); diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleVoterTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleVoterTest.php index 109d2d5f0f5b2..dfa0555652fba 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleVoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleVoterTest.php @@ -30,16 +30,6 @@ public function testVoteUsingTokenThatReturnsRoleNames($roles, $attributes, $exp $this->assertSame($expected, $voter->vote($this->getTokenWithRoleNames($roles), null, $attributes)); } - /** - * @dataProvider getVoteTests - */ - public function testGetVoteUsingTokenThatReturnsRoleNames($roles, $attributes, $expected) - { - $voter = new RoleVoter(); - - $this->assertSame($expected, $voter->getVote($this->getTokenWithRoleNames($roles), null, $attributes)->getAccess()); - } - public static function getVoteTests() { return [ diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/TraceableVoterTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/TraceableVoterTest.php index a2245931bf9f8..2039f19954abd 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/TraceableVoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/TraceableVoterTest.php @@ -15,8 +15,10 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\CacheableVoterInterface; use Symfony\Component\Security\Core\Authorization\Voter\TraceableVoter; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Event\VoteEvent; +use Symfony\Component\Security\Core\Tests\Fixtures\DummyVoterWithObject; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; class TraceableVoterTest extends TestCase @@ -53,28 +55,26 @@ public function testVote() $this->assertSame(VoterInterface::ACCESS_DENIED, $result); } - public function testGetVote() + public function testVoteWithObject() { $voter = $this->createMock(VoterInterface::class); $eventDispatcher = $this->createMock(EventDispatcherInterface::class); $token = $this->createStub(TokenInterface::class); - $voter - ->expects($this->once()) - ->method('vote') - ->with($token, 'anysubject', ['attr1']) - ->willReturn(VoterInterface::ACCESS_DENIED); + $voter = new DummyVoterWithObject(new Vote(VoterInterface::ACCESS_DENIED)); $eventDispatcher ->expects($this->once()) ->method('dispatch') - ->with(new VoteEvent($voter, 'anysubject', ['attr1'], VoterInterface::ACCESS_DENIED), 'debug.security.authorization.vote'); + ->with(new VoteEvent($voter, 'anysubject', ['attr1'], new Vote(VoterInterface::ACCESS_DENIED)), 'debug.security.authorization.vote'); $sut = new TraceableVoter($voter, $eventDispatcher); - $result = $sut->getVote($token, 'anysubject', ['attr1']); + $vote = null; + $result = $sut->vote($token, 'anysubject', ['attr1'], $vote); - $this->assertSame(VoterInterface::ACCESS_DENIED, $result->getAccess()); + $this->assertSame(VoterInterface::ACCESS_DENIED, $result); + $this->assertSame(VoterInterface::ACCESS_DENIED, $vote->getAccess()); } public function testSupportsAttributeOnCacheable() diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoterTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoterTest.php index 34a820b081069..602c61ab08a34 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoterTest.php @@ -63,27 +63,12 @@ public function testVote(VoterInterface $voter, array $attributes, $expectedVote $this->assertEquals($expectedVote, $voter->vote($this->token, $object, $attributes), $message); } - /** - * @dataProvider getTests - */ - public function testGetVote(VoterInterface $voter, array $attributes, $expectedVote, $object, $message) - { - $this->assertEquals($expectedVote, $voter->getVote($this->token, $object, $attributes)->getAccess(), $message); - } - public function testVoteWithTypeError() { $this->expectException(\TypeError::class); $this->expectExceptionMessage('Should error'); (new TypeErrorVoterTest_Voter())->vote($this->token, new \stdClass(), ['EDIT']); } - - public function testGetVoteWithTypeError() - { - $this->expectException(\TypeError::class); - $this->expectExceptionMessage('Should error'); - (new TypeErrorVoterTest_Voter())->getVote($this->token, new \stdClass(), ['EDIT']); - } } class VoterTest_Voter extends Voter diff --git a/src/Symfony/Component/Security/Core/Tests/Exception/AccessDeniedExceptionTest.php b/src/Symfony/Component/Security/Core/Tests/Exception/AccessDeniedExceptionTest.php deleted file mode 100644 index 55c5ffd824a46..0000000000000 --- a/src/Symfony/Component/Security/Core/Tests/Exception/AccessDeniedExceptionTest.php +++ /dev/null @@ -1,73 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Core\Tests\Exception; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\Security\Core\Authorization\AccessDecision; -use Symfony\Component\Security\Core\Authorization\Voter\Vote; -use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; -use Symfony\Component\Security\Core\Exception\AccessDeniedException; - -final class AccessDeniedExceptionTest extends TestCase -{ - /** - * @dataProvider getAccessDescisions - */ - public function testSetAccessDecision(AccessDecision $accessDecision, string $expected) - { - $exception = new AccessDeniedException(); - $exception->setAccessDecision($accessDecision); - - $this->assertSame($expected, $exception->getMessage()); - } - - public function getAccessDescisions(): \Generator - { - yield [ - new AccessDecision(VoterInterface::ACCESS_DENIED, [ - new Vote(VoterInterface::ACCESS_DENIED, 'foo'), - new Vote(VoterInterface::ACCESS_DENIED, 'bar'), - new Vote(VoterInterface::ACCESS_DENIED, 'baz'), - ]), - 'Access Denied.'.PHP_EOL.'Decision messages are "foo" and "bar" and "baz"', - ]; - - yield [ - new AccessDecision(VoterInterface::ACCESS_DENIED,), - 'Access Denied.', - ]; - - yield [ - new AccessDecision(VoterInterface::ACCESS_DENIED,[ - new Vote(VoterInterface::ACCESS_ABSTAIN,'foo'), - new Vote(VoterInterface::ACCESS_DENIED,'bar'), - new Vote(VoterInterface::ACCESS_ABSTAIN,'baz'), - ]), - 'Access Denied.'.PHP_EOL.'Decision message is "bar"', - ]; - - yield [ - new AccessDecision(VoterInterface::ACCESS_GRANTED,[ - new Vote(VoterInterface::ACCESS_DENIED,'foo'), - ]), - 'Access Denied.'.PHP_EOL.'Decision message is "foo"', - ]; - - yield [ - new AccessDecision(VoterInterface::ACCESS_GRANTED,[ - new Vote(VoterInterface::ACCESS_DENIED,['foo', 'bar']), - new Vote(VoterInterface::ACCESS_DENIED,['baz', 'qux']), - ]), - 'Access Denied.'.PHP_EOL.'Decision messages are "foo, bar" and "baz, qux"', - ]; - } -} \ No newline at end of file diff --git a/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyVoter.php b/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyVoter.php index f267c6c06be04..1f923423a21ed 100644 --- a/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyVoter.php +++ b/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyVoter.php @@ -12,7 +12,6 @@ namespace Symfony\Component\Security\Core\Tests\Fixtures; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; final class DummyVoter implements VoterInterface @@ -20,8 +19,4 @@ final class DummyVoter implements VoterInterface public function vote(TokenInterface $token, $subject, array $attributes): int { } - - public function getVote(TokenInterface $token, mixed $subject, array $attributes): VoterInterface - { - } } diff --git a/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyVoterWithObject.php b/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyVoterWithObject.php new file mode 100644 index 0000000000000..c39515e57f6df --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyVoterWithObject.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Tests\Fixtures; + +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\VoteInterface; +use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; + +final class DummyVoterWithObject implements VoterInterface +{ + public function __construct(private ?VoteInterface $vote = null) + { + } + + public function vote(TokenInterface $token, $subject, array $attributes, ?VoteInterface &$vote = null): int + { + $vote = $this->vote; + return $vote->getAccess(); + } +} diff --git a/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php b/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php index e7b150a219fd9..c202f7e511a09 100644 --- a/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php +++ b/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php @@ -20,7 +20,6 @@ use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; -use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\Exception\RuntimeException; use Symfony\Component\Security\Http\Attribute\IsGranted; @@ -61,13 +60,10 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo } } - if (method_exists($this->authChecker, 'getDecision')) { - $decision = $this->authChecker->getDecision($attribute->attribute, $subject); - } else { - $decision = new AccessDecision($this->authChecker->isGranted($attribute->attribute, $subject) ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED); - } + $accessDecision = null; + $decision = $this->authChecker->isGranted($attribute->attribute, $subject, $accessDecision); - if (!$decision->isGranted()) { + if (!$decision) { $message = $attribute->message ?: \sprintf('Access Denied by #[IsGranted(%s)] on controller', $this->getIsGrantedString($attribute)); if ($statusCode = $attribute->statusCode) { @@ -77,7 +73,7 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo $accessDeniedException = new AccessDeniedException($message, code: $attribute->exceptionCode ?? 403); $accessDeniedException->setAttributes($attribute->attribute); $accessDeniedException->setSubject($subject); - $accessDeniedException->setAccessDecision($decision); + $accessDeniedException->setAccessDecision($accessDecision ?? new AccessDecision($decision)); throw $accessDeniedException; } diff --git a/src/Symfony/Component/Security/Http/Firewall/AccessListener.php b/src/Symfony/Component/Security/Http/Firewall/AccessListener.php index cef1edbc1afa3..7dd0799daed82 100644 --- a/src/Symfony/Component/Security/Http/Firewall/AccessListener.php +++ b/src/Symfony/Component/Security/Http/Firewall/AccessListener.php @@ -18,7 +18,6 @@ use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter; -use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Http\AccessMapInterface; use Symfony\Component\Security\Http\Event\LazyResponseEvent; @@ -75,17 +74,15 @@ public function authenticate(RequestEvent $event): void $token = $this->tokenStorage->getToken() ?? new NullToken(); - if (method_exists($this->accessDecisionManager, 'getDecision')) { - $decision = $this->accessDecisionManager->getDecision($token, $attributes, $request, true); - } else { - $decision = new AccessDecision( - $this->accessDecisionManager->decide($token, $attributes, $request, true) - ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED - ); + $accessDecision = null; + $decision = $this->accessDecisionManager->decide($token, $attributes, $request, true, $accessDecision); + + if(! $accessDecision instanceof AccessDecision) { + $accessDecision = new AccessDecision($decision); } - if ($decision->isDenied()) { - throw $this->createAccessDeniedException($request, $attributes, $decision); + if ($accessDecision->isDenied()) { + throw $this->createAccessDeniedException($request, $attributes, $accessDecision); } } diff --git a/src/Symfony/Component/Security/Http/Firewall/SwitchUserListener.php b/src/Symfony/Component/Security/Http/Firewall/SwitchUserListener.php index 1494e7843e9c3..bcfc0b62d83ed 100644 --- a/src/Symfony/Component/Security/Http/Firewall/SwitchUserListener.php +++ b/src/Symfony/Component/Security/Http/Firewall/SwitchUserListener.php @@ -21,7 +21,6 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; -use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException; use Symfony\Component\Security\Core\Exception\AuthenticationException; @@ -156,19 +155,17 @@ private function attemptSwitchUser(Request $request, string $username): ?TokenIn throw $e; } - if (method_exists($this->accessDecisionManager, 'getDecision')) { - $decision = $this->accessDecisionManager->getDecision($token, [$this->role], $user); - } else { - $decision = new AccessDecision( - $this->accessDecisionManager->decide($token, [$this->role], $user) - ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED - ); + $accessDecision = null; + $decision = $this->accessDecisionManager->decide($token, [$this->role], $user, false, $accessDecision); + if(! $accessDecision instanceof AccessDecision) { + $accessDecision = new AccessDecision($decision); } - if ($decision->isDenied()) { + + if (!$decision) { $exception = new AccessDeniedException(); $exception->setAttributes($this->role); - $exception->setAccessDecision($decision); + $exception->setAccessDecision($accessDecision); throw $exception; } diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeListenerTest.php index 2d03b7ac357ea..af2607e51c219 100644 --- a/src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeListenerTest.php @@ -18,6 +18,7 @@ use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Http\EventListener\IsGrantedAttributeListener; @@ -434,4 +435,36 @@ public function testAccessDeniedExceptionWithExceptionCode() $listener->onKernelControllerArguments($event); } + + public function testAccessDeniedExceptionWithExceptionCodeAndWithObject() + { + $authChecker = new class() implements AuthorizationCheckerInterface { + public function isGranted(mixed $attribute, mixed $subject = null , ?AccessDecision &$accessDecision = null ): bool + { + $accessDecision = new AccessDecision(false, [], 'User denied'); + return $accessDecision->getAccess(); + } + }; + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsController(), 'exceptionCodeInAccessDeniedException'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + + $this->expectException(AccessDeniedException::class); + $this->expectExceptionMessage('Exception Code'); + $this->expectExceptionCode(10010); + + try { + $listener->onKernelControllerArguments($event); + } catch (AccessDeniedException $exception) { + $this->assertSame('User denied', $exception->getAccessDecision()->getMessage()); + throw $exception; + } + } } diff --git a/src/Symfony/Component/Security/Http/Tests/Firewall/AccessListenerTest.php b/src/Symfony/Component/Security/Http/Tests/Firewall/AccessListenerTest.php index 26f1ddf8c5bf2..831702a0e0c75 100644 --- a/src/Symfony/Component/Security/Http/Tests/Firewall/AccessListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Firewall/AccessListenerTest.php @@ -19,11 +19,11 @@ use Symfony\Component\Security\Core\Authentication\Token\NullToken; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter; -use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\Component\Security\Http\AccessMapInterface; @@ -32,10 +32,7 @@ class AccessListenerTest extends TestCase { - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testHandleWhenTheAccessDecisionManagerDecidesToRefuseAccess(string $decideFunction, bool $useVoteObject) + public function testHandleWhenTheAccessDecisionManagerDecidesToRefuseAccess() { $request = new Request(); @@ -56,12 +53,12 @@ public function testHandleWhenTheAccessDecisionManagerDecidesToRefuseAccess(stri ->willReturn($token) ; - $accessDecisionManager = $this->getAccessManager($useVoteObject); + $accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class); $accessDecisionManager ->expects($this->once()) - ->method($decideFunction) + ->method('decide') ->with($this->equalTo($token), $this->equalTo(['foo' => 'bar']), $this->equalTo($request)) - ->willReturn($useVoteObject ? new AccessDecision(VoterInterface::ACCESS_DENIED) : false) + ->willReturn(false) ; $listener = new AccessListener( @@ -131,10 +128,7 @@ public function testHandleWhenAccessMapReturnsEmptyAttributes() $listener(new LazyResponseEvent($event)); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testHandleWhenTheSecurityTokenStorageHasNoToken(string $decideFunction, bool $useVoteObject) + public function testHandleWhenTheSecurityTokenStorageHasNoToken() { $tokenStorage = new TokenStorage(); $request = new Request(); @@ -146,11 +140,11 @@ public function testHandleWhenTheSecurityTokenStorageHasNoToken(string $decideFu ->willReturn([['foo' => 'bar'], null]) ; - $accessDecisionManager = $this->getAccessManager($useVoteObject); + $accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class); $accessDecisionManager->expects($this->once()) - ->method($decideFunction) + ->method('decide') ->with($this->isInstanceOf(NullToken::class)) - ->willReturn($useVoteObject ? new AccessDecision(VoterInterface::ACCESS_DENIED) : false); + ->willReturn(false); $listener = new AccessListener( $tokenStorage, @@ -164,10 +158,7 @@ public function testHandleWhenTheSecurityTokenStorageHasNoToken(string $decideFu $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testHandleWhenPublicAccessIsAllowed(string $decideFunction, bool $useVoteObject) + public function testHandleWhenPublicAccessIsAllowed() { $tokenStorage = new TokenStorage(); $request = new Request(); @@ -179,11 +170,11 @@ public function testHandleWhenPublicAccessIsAllowed(string $decideFunction, bool ->willReturn([[AuthenticatedVoter::PUBLIC_ACCESS], null]) ; - $accessDecisionManager = $this->getAccessManager($useVoteObject); + $accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class); $accessDecisionManager->expects($this->once()) - ->method($decideFunction) + ->method('decide') ->with($this->isInstanceOf(NullToken::class), [AuthenticatedVoter::PUBLIC_ACCESS]) - ->willReturn($useVoteObject ? new AccessDecision(VoterInterface::ACCESS_GRANTED) : true); + ->willReturn(true); $listener = new AccessListener( $tokenStorage, @@ -195,10 +186,7 @@ public function testHandleWhenPublicAccessIsAllowed(string $decideFunction, bool $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testHandleWhenPublicAccessWhileAuthenticated(string $decideFunction, bool $useVoteObject) + public function testHandleWhenPublicAccessWhileAuthenticated() { $token = new UsernamePasswordToken(new InMemoryUser('Wouter', null, ['ROLE_USER']), 'main', ['ROLE_USER']); $tokenStorage = new TokenStorage(); @@ -212,11 +200,11 @@ public function testHandleWhenPublicAccessWhileAuthenticated(string $decideFunct ->willReturn([[AuthenticatedVoter::PUBLIC_ACCESS], null]) ; - $accessDecisionManager = $this->getAccessManager($useVoteObject); + $accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class); $accessDecisionManager->expects($this->once()) - ->method($decideFunction) + ->method('decide') ->with($this->equalTo($token), [AuthenticatedVoter::PUBLIC_ACCESS]) - ->willReturn($useVoteObject ? new AccessDecision(VoterInterface::ACCESS_GRANTED) : true); + ->willReturn(true); $listener = new AccessListener( $tokenStorage, @@ -228,10 +216,7 @@ public function testHandleWhenPublicAccessWhileAuthenticated(string $decideFunct $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testHandleMWithultipleAttributesShouldBeHandledAsAnd(string $decideFunction, bool $useVoteObject) + public function testHandleMWithultipleAttributesShouldBeHandledAsAnd() { $request = new Request(); @@ -248,12 +233,12 @@ public function testHandleMWithultipleAttributesShouldBeHandledAsAnd(string $dec $tokenStorage = new TokenStorage(); $tokenStorage->setToken($authenticatedToken); - $accessDecisionManager = $this->getAccessManager($useVoteObject); + $accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class); $accessDecisionManager ->expects($this->once()) - ->method($decideFunction) + ->method('decide') ->with($this->equalTo($authenticatedToken), $this->equalTo(['foo' => 'bar', 'bar' => 'baz']), $this->equalTo($request), true) - ->willReturn($useVoteObject ? new AccessDecision(VoterInterface::ACCESS_GRANTED) : true) + ->willReturn(true) ; $listener = new AccessListener( @@ -296,27 +281,54 @@ public function testConstructWithTrueExceptionOnNoToken() new AccessListener($tokenStorage, $this->createMock(AccessDecisionManagerInterface::class), $accessMap, true); } - public function provideDataWithAndWithoutVoteObject() + public function testHandleWhenTheAccessDecisionManagerDecidesToRefuseAccessWithAccessDecisionObject() { - yield [ - 'decideFunction' => 'decide', - 'useVoteObject' => false, - ]; - - yield [ - 'decideFunction' => 'getDecision', - 'useVoteObject' => true, - ]; - } + $request = new Request(); + + $accessMap = $this->createMock(AccessMapInterface::class); + $accessMap + ->expects($this->any()) + ->method('getPatterns') + ->with($this->equalTo($request)) + ->willReturn([['foo' => 'bar'], null]) + ; + + $token = new class extends AbstractToken { + public function getCredentials(): mixed + { + } + }; + + $tokenStorage = $this->createMock(TokenStorageInterface::class); + $tokenStorage + ->expects($this->any()) + ->method('getToken') + ->willReturn($token) + ; + + $accessDecisionManager = new class implements AccessDecisionManagerInterface { + function decide(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false, ?AccessDecision &$accessDecision = null): bool + { + $accessDecision = new AccessDecision(false,[], 'not allowed'); + return $accessDecision->getAccess(); + } + }; + + $listener = new AccessListener( + $tokenStorage, + $accessDecisionManager, + $accessMap + ); + + $this->expectException(AccessDeniedException::class); + + try { + $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); + } catch (AccessDeniedException $exception) { + $this->assertFalse($exception->getAccessDecision()->getAccess()); + $this->assertSame('not allowed', $exception->getAccessDecision()->getMessage()); + throw $exception; + } - public function getAccessManager(bool $withObject) - { - return $withObject ? - $this - ->getMockBuilder(AccessDecisionManagerInterface::class) - ->onlyMethods(['decide']) - ->addMethods(['getDecision']) - ->getMock() : - $this->createMock(AccessDecisionManagerInterface::class); } } diff --git a/src/Symfony/Component/Security/Http/Tests/Firewall/SwitchUserListenerTest.php b/src/Symfony/Component/Security/Http/Tests/Firewall/SwitchUserListenerTest.php index ac41d312d56f2..4ca2028f7aa71 100644 --- a/src/Symfony/Component/Security/Http/Tests/Firewall/SwitchUserListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Firewall/SwitchUserListenerTest.php @@ -23,7 +23,7 @@ use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; -use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException; use Symfony\Component\Security\Core\User\InMemoryUser; @@ -139,10 +139,7 @@ public function testExitUserDispatchesEventWithRefreshedUser() $listener($this->event); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testSwitchUserIsDisallowed($accessDecisionManager, string $decideFunction, bool $returnAsObject) + public function testSwitchUserIsDisallowed() { $token = new UsernamePasswordToken(new InMemoryUser('username', '', ['ROLE_FOO']), 'key', ['ROLE_FOO']); $user = new InMemoryUser('username', 'password', []); @@ -150,55 +147,49 @@ public function testSwitchUserIsDisallowed($accessDecisionManager, string $decid $this->tokenStorage->setToken($token); $this->request->query->set('_switch_user', 'kuba'); - $accessDecisionManager->expects($this->once()) - ->method($decideFunction)->with($token, ['ROLE_ALLOWED_TO_SWITCH']) - ->willReturn($returnAsObject ? new AccessDecision(VoterInterface::ACCESS_DENIED) : false); + $this->accessDecisionManager->expects($this->once()) + ->method('decide')->with($token, ['ROLE_ALLOWED_TO_SWITCH']) + ->willReturn(false); - $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $accessDecisionManager); + $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager); $this->expectException(AccessDeniedException::class); $listener($this->event); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testSwitchUserTurnsAuthenticationExceptionTo403($accessDecisionManager, string $decideFunction, bool $returnAsObject) + public function testSwitchUserTurnsAuthenticationExceptionTo403() { $token = new UsernamePasswordToken(new InMemoryUser('username', '', ['ROLE_ALLOWED_TO_SWITCH']), 'key', ['ROLE_ALLOWED_TO_SWITCH']); $this->tokenStorage->setToken($token); $this->request->query->set('_switch_user', 'not-existing'); - $accessDecisionManager->expects($this->never()) - ->method($decideFunction); + $this->accessDecisionManager->expects($this->never()) + ->method('decide'); - $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $accessDecisionManager); + $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager); $this->expectException(AccessDeniedException::class); $listener($this->event); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testSwitchUser($accessDecisionManager, string $decideFunction, bool $returnAsObject) + public function testSwitchUser() { $token = new UsernamePasswordToken(new InMemoryUser('username', '', ['ROLE_FOO']), 'key', ['ROLE_FOO']); $this->tokenStorage->setToken($token); $this->request->query->set('_switch_user', 'kuba'); - $accessDecisionManager->expects($this->once()) - ->method($decideFunction)->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier())) - ->willReturn($returnAsObject ? new AccessDecision(VoterInterface::ACCESS_GRANTED) : true); + $this->accessDecisionManager->expects($this->once()) + ->method('decide')->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier())) + ->willReturn(true); $this->userChecker->expects($this->once()) ->method('checkPostAuth')->with($this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier()), $token); - $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $accessDecisionManager); + $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager); $listener($this->event); $this->assertSame([], $this->request->query->all()); @@ -206,10 +197,7 @@ public function testSwitchUser($accessDecisionManager, string $decideFunction, b $this->assertInstanceOf(UsernamePasswordToken::class, $this->tokenStorage->getToken()); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testSwitchUserAlreadySwitched($accessDecisionManager, string $decideFunction, bool $returnAsObject) + public function testSwitchUserAlreadySwitched() { $originalToken = new UsernamePasswordToken(new InMemoryUser('original', null, ['ROLE_FOO']), 'key', ['ROLE_FOO']); $alreadySwitchedToken = new SwitchUserToken(new InMemoryUser('switched_1', null, ['ROLE_BAR']), 'key', ['ROLE_BAR'], $originalToken); @@ -220,17 +208,17 @@ public function testSwitchUserAlreadySwitched($accessDecisionManager, string $de $this->request->query->set('_switch_user', 'kuba'); $targetsUser = $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier()); - $accessDecisionManager->expects($this->once()) - ->method($decideFunction)->with(self::callback(function (TokenInterface $token) use ($originalToken, $tokenStorage) { + $this->accessDecisionManager->expects($this->once()) + ->method('decide')->with(self::callback(function (TokenInterface $token) use ($originalToken, $tokenStorage) { // the token storage should also contain the original token for voters depending on it return $token === $originalToken && $tokenStorage->getToken() === $originalToken; }), ['ROLE_ALLOWED_TO_SWITCH'], $targetsUser) - ->willReturn($returnAsObject ? new AccessDecision(VoterInterface::ACCESS_GRANTED) : true); + ->willReturn(true); $this->userChecker->expects($this->once()) ->method('checkPostAuth')->with($targetsUser); - $listener = new SwitchUserListener($tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $accessDecisionManager, null, '_switch_user', 'ROLE_ALLOWED_TO_SWITCH', null, false); + $listener = new SwitchUserListener($tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager, null, '_switch_user', 'ROLE_ALLOWED_TO_SWITCH', null, false); $listener($this->event); $this->assertSame([], $this->request->query->all()); @@ -240,10 +228,7 @@ public function testSwitchUserAlreadySwitched($accessDecisionManager, string $de $this->assertSame($originalToken, $tokenStorage->getToken()->getOriginalToken()); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testSwitchUserWorksWithFalsyUsernames($accessDecisionManager, string $decideFunction, bool $returnAsObject) + public function testSwitchUserWorksWithFalsyUsernames() { $token = new UsernamePasswordToken(new InMemoryUser('kuba', '', ['ROLE_FOO']), 'key', ['ROLE_FOO']); @@ -252,14 +237,14 @@ public function testSwitchUserWorksWithFalsyUsernames($accessDecisionManager, st $this->userProvider->createUser($user = new InMemoryUser('0', null)); - $accessDecisionManager->expects($this->once()) - ->method($decideFunction)->with($token, ['ROLE_ALLOWED_TO_SWITCH']) - ->willReturn($returnAsObject ? new AccessDecision(VoterInterface::ACCESS_GRANTED) : true); + $this->accessDecisionManager->expects($this->once()) + ->method('decide')->with($token, ['ROLE_ALLOWED_TO_SWITCH']) + ->willReturn(true); $this->userChecker->expects($this->once()) ->method('checkPostAuth')->with($this->callback(fn ($argUser) => $user->isEqualTo($argUser))); - $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $accessDecisionManager); + $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager); $listener($this->event); $this->assertSame([], $this->request->query->all()); @@ -267,10 +252,7 @@ public function testSwitchUserWorksWithFalsyUsernames($accessDecisionManager, st $this->assertInstanceOf(UsernamePasswordToken::class, $this->tokenStorage->getToken()); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testSwitchUserKeepsOtherQueryStringParameters($accessDecisionManager, string $decideFunction, bool $returnAsObject) + public function testSwitchUserKeepsOtherQueryStringParameters() { $token = new UsernamePasswordToken(new InMemoryUser('username', '', ['ROLE_FOO']), 'key', ['ROLE_FOO']); @@ -282,24 +264,21 @@ public function testSwitchUserKeepsOtherQueryStringParameters($accessDecisionMan ]); $targetsUser = $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier()); - $accessDecisionManager->expects($this->once()) - ->method($decideFunction)->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $targetsUser) - ->willReturn($returnAsObject ? new AccessDecision(VoterInterface::ACCESS_GRANTED) : true); + $this->accessDecisionManager->expects($this->once()) + ->method('decide')->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $targetsUser) + ->willReturn(true); $this->userChecker->expects($this->once()) ->method('checkPostAuth')->with($targetsUser); - $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $accessDecisionManager); + $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager); $listener($this->event); $this->assertSame('page=3§ion=2', $this->request->server->get('QUERY_STRING')); $this->assertInstanceOf(UsernamePasswordToken::class, $this->tokenStorage->getToken()); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testSwitchUserWithReplacedToken($accessDecisionManager, string $decideFunction, bool $returnAsObject) + public function testSwitchUserWithReplacedToken() { $user = new InMemoryUser('username', 'password', []); $token = new UsernamePasswordToken($user, 'provider123', ['ROLE_FOO']); @@ -310,9 +289,9 @@ public function testSwitchUserWithReplacedToken($accessDecisionManager, string $ $this->tokenStorage->setToken($token); $this->request->query->set('_switch_user', 'kuba'); - $accessDecisionManager->expects($this->any()) - ->method($decideFunction)->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier())) - ->willReturn($returnAsObject ? new AccessDecision(VoterInterface::ACCESS_GRANTED) : true); + $this->accessDecisionManager->expects($this->any()) + ->method('decide')->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier())) + ->willReturn(true); $dispatcher = $this->createMock(EventDispatcherInterface::class); $dispatcher @@ -330,7 +309,7 @@ public function testSwitchUserWithReplacedToken($accessDecisionManager, string $ SecurityEvents::SWITCH_USER ); - $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $accessDecisionManager, null, '_switch_user', 'ROLE_ALLOWED_TO_SWITCH', $dispatcher); + $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager, null, '_switch_user', 'ROLE_ALLOWED_TO_SWITCH', $dispatcher); $listener($this->event); $this->assertSame($replacedToken, $this->tokenStorage->getToken()); @@ -347,10 +326,7 @@ public function testSwitchUserThrowsAuthenticationExceptionIfNoCurrentToken() $listener($this->event); } - /** - * @dataProvider provideDataWithAndWithoutVoteObject - */ - public function testSwitchUserStateless($accessDecisionManager, string $decideFunction, bool $returnAsObject) + public function testSwitchUserStateless() { $token = new UsernamePasswordToken(new InMemoryUser('username', '', ['ROLE_FOO']), 'key', ['ROLE_FOO']); @@ -358,15 +334,14 @@ public function testSwitchUserStateless($accessDecisionManager, string $decideFu $this->request->query->set('_switch_user', 'kuba'); $targetsUser = $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier()); - - $accessDecisionManager->expects($this->once()) - ->method($decideFunction)->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $targetsUser) - ->willReturn($returnAsObject ? new AccessDecision(VoterInterface::ACCESS_GRANTED) : true); + $this->accessDecisionManager->expects($this->once()) + ->method('decide')->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $targetsUser) + ->willReturn(true); $this->userChecker->expects($this->once()) ->method('checkPostAuth')->with($targetsUser); - $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $accessDecisionManager, null, '_switch_user', 'ROLE_ALLOWED_TO_SWITCH', null, true); + $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager, null, '_switch_user', 'ROLE_ALLOWED_TO_SWITCH', null, true); $listener($this->event); $this->assertInstanceOf(UsernamePasswordToken::class, $this->tokenStorage->getToken()); @@ -401,22 +376,34 @@ public function testSwitchUserRefreshesOriginalToken() $listener($this->event); } - public function provideDataWithAndWithoutVoteObject() + public function testSwitchUserIsDisallowedWithObject() { - yield [ - 'accessDecisionManager' => $this->createMock(AccessDecisionManagerInterface::class), - 'decideFunction' => 'decide', - 'returnAsObject' => false, - ]; - - yield [ - 'accessDecisionManager' => $this - ->getMockBuilder(AccessDecisionManagerInterface::class) - ->onlyMethods(['decide']) - ->addMethods(['getDecision']) - ->getMock(), - 'decideFunction' => 'getDecision', - 'returnAsObject' => true, - ]; + $token = new UsernamePasswordToken(new InMemoryUser('username', '', ['ROLE_FOO']), 'key', ['ROLE_FOO']); + $user = new InMemoryUser('username', 'password', []); + + $this->tokenStorage->setToken($token); + $this->request->query->set('_switch_user', 'kuba'); + + $accessDecision = null; + $accessDecisionManager = new class implements AccessDecisionManagerInterface { + function decide(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false, ?AccessDecision &$accessDecision = null): bool + { + $accessDecision = new AccessDecision(false, [new Vote(false)], 'User unable to switch'); + return $accessDecision->getAccess(); + } + }; + + $this->expectException(AccessDeniedException::class); + + try{ + $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $accessDecisionManager); + $listener($this->event); + } catch (AccessDeniedException $exception) { + $this->assertFalse($exception->getAccessDecision()->getAccess()); + $this->assertSame('User unable to switch', $exception->getAccessDecision()->getMessage()); + $this->assertCount(1, $exception->getAccessDecision()->getVotes()); + + throw $exception; + } } } From 17cbdf8eb2b913ced88ca7c1b902cc00843e76fe Mon Sep 17 00:00:00 2001 From: eltharin Date: Tue, 11 Feb 2025 12:44:37 +0100 Subject: [PATCH 12/12] voteevent --- .../Bundle/SecurityBundle/EventListener/VoteListener.php | 4 +++- .../Core/Authorization/TraceableAccessDecisionManager.php | 4 ++-- src/Symfony/Component/Security/Core/Event/VoteEvent.php | 8 +++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php b/src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php index 33f1590926aa7..81d934958199c 100644 --- a/src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php +++ b/src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php @@ -31,7 +31,9 @@ public function __construct( public function onVoterVote(VoteEvent $event): void { - $this->traceableAccessDecisionManager->addVoterVote($event->getVoter(), $event->getAttributes(), $event->getVote(true)); + $voteObj = null; + $vote = $event->getVote($voteObj); + $this->traceableAccessDecisionManager->addVoterVote($event->getVoter(), $event->getAttributes(), $voteObj ?? $vote); } public static function getSubscribedEvents(): array diff --git a/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php b/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php index 70481c5e7607d..663ca11ed57b6 100644 --- a/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php +++ b/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php @@ -68,8 +68,8 @@ public function decide(TokenInterface $token, array $attributes, mixed $object = /** * Adds voter vote and class to the voter details. * - * @param array $attributes attributes used for the vote - * @param int $vote vote of the voter + * @param array $attributes attributes used for the vote + * @param VoteInterface|int $vote vote of the voter */ public function addVoterVote(VoterInterface $voter, array $attributes, VoteInterface|int $vote): void { diff --git a/src/Symfony/Component/Security/Core/Event/VoteEvent.php b/src/Symfony/Component/Security/Core/Event/VoteEvent.php index b8482bfe3308d..28be0f41e0007 100644 --- a/src/Symfony/Component/Security/Core/Event/VoteEvent.php +++ b/src/Symfony/Component/Security/Core/Event/VoteEvent.php @@ -47,12 +47,10 @@ public function getAttributes(): array return $this->attributes; } - public function getVote($asObject = false): VoteInterface|int + public function getVote(?VoteInterface &$vote = null): int { - if ($this->vote instanceof VoteInterface && !$asObject) { - return $this->vote->getAccess(); - } + $vote = $this->vote; - return $this->vote; + return $this->vote instanceof VoteInterface ? $this->vote->getAccess() : $this->vote; } }