diff --git a/UPGRADE-7.1.md b/UPGRADE-7.1.md index 9313b5e02f197..12938637ce814 100644 --- a/UPGRADE-7.1.md +++ b/UPGRADE-7.1.md @@ -22,6 +22,20 @@ FrameworkBundle * Mark classes `ConfigBuilderCacheWarmer`, `Router`, `SerializerCacheWarmer`, `TranslationsCacheWarmer`, `Translator` and `ValidatorCacheWarmer` as `final` +Security +-------- + + * Add method `getDecision()` to `AccessDecisionStrategyInterface` + * Deprecate `AccessDecisionStrategyInterface::decide()` in favor of `AccessDecisionStrategyInterface::getDecision()` + * Add method `getVote()` to `VoterInterface` + * Deprecate `VoterInterface::vote()` in favor of `AccessDecisionStrategyInterface::getVote()` + * Deprecate returning `bool` from `Voter::voteOnAttribute()` (it must return a `Vote`) + * Add method `getDecision()` to `AccessDecisionManagerInterface` + * Deprecate `AccessDecisionManagerInterface::decide()` in favor of `AccessDecisionManagerInterface::getDecision()` + * Add method `getDecision()` to `AuthorizationCheckerInterface` + * Add methods `setAccessDecision()` and `getAccessDecision()` to `AccessDeniedException` + * Add method `getDecision()` to `Security` + SecurityBundle -------------- diff --git a/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php b/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php index f0c1d98ee01be..ca25a1245243f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php +++ b/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php @@ -35,6 +35,7 @@ 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\Exception\AccessDeniedException; use Symfony\Component\Security\Core\User\UserInterface; @@ -210,10 +211,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 = $checker->isGranted($attribute, $subject) ? AccessDecision::createGranted() : AccessDecision::createDenied(); + } + + if (!$decision->isGranted()) { $exception = $this->createAccessDeniedException($message); $exception->setAttributes([$attribute]); $exception->setSubject($subject); + $exception->setAccessDecision($decision); throw $exception; } diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index af934f35df91f..ab522fa709b08 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -95,7 +95,7 @@ "symfony/scheduler": "<6.4.4|>=7.0.0,<7.0.4", "symfony/serializer": "<6.4", "symfony/security-csrf": "<6.4", - "symfony/security-core": "<6.4", + "symfony/security-core": "<7.1", "symfony/stopwatch": "<6.4", "symfony/translation": "<6.4", "symfony/twig-bridge": "<6.4", diff --git a/src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php b/src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php index 34ca91c3a7735..34ef8ae2088e1 100644 --- a/src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php +++ b/src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php @@ -33,7 +33,7 @@ public function __construct(TraceableAccessDecisionManager $traceableAccessDecis public function onVoterVote(VoteEvent $event): void { - $this->traceableAccessDecisionManager->addVoterVote($event->getVoter(), $event->getAttributes(), $event->getVote()); + $this->traceableAccessDecisionManager->addVoterVote($event->getVoter(), $event->getAttributes(), $event->getVoteDecision()); } 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 4dd0b021fe9d2..758ddefe91f11 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig +++ b/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig @@ -476,16 +476,17 @@ attribute {{ voter_detail['attributes'][0] }} {% endif %} - {% if voter_detail['vote'] == constant('Symfony\\Component\\Security\\Core\\Authorization\\Voter\\VoterInterface::ACCESS_GRANTED') %} + {% if voter_detail['vote'].access == 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') %} + {% elseif voter_detail['vote'].access == 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') %} + {% elseif voter_detail['vote'].access == constant('Symfony\\Component\\Security\\Core\\Authorization\\Voter\\VoterInterface::ACCESS_DENIED') %} ACCESS DENIED {% else %} - unknown ({{ voter_detail['vote'] }}) + unknown ({{ voter_detail['vote'].access }}) {% endif %} + {{ voter_detail['vote'].message|default('~') }} {% endfor %} diff --git a/src/Symfony/Bundle/SecurityBundle/Security.php b/src/Symfony/Bundle/SecurityBundle/Security.php index c4b505d8981c7..0a7de159e4aa6 100644 --- a/src/Symfony/Bundle/SecurityBundle/Security.php +++ b/src/Symfony/Bundle/SecurityBundle/Security.php @@ -17,6 +17,7 @@ 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\Exception\LogicException; use Symfony\Component\Security\Core\Exception\LogoutException; @@ -59,8 +60,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 $checker->isGranted($attribute, $subject) ? AccessDecision::createGranted() : AccessDecision::createDenied(); } 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 e4173b1fdc659..5c7accd837493 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DataCollector/SecurityDataCollectorTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DataCollector/SecurityDataCollectorTest.php @@ -28,6 +28,7 @@ use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; 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\VoterInterface; use Symfony\Component\Security\Core\Role\RoleHierarchy; use Symfony\Component\Security\Core\User\InMemoryUser; @@ -464,4 +465,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): Vote + { + } } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/EventListener/VoteListenerTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/EventListener/VoteListenerTest.php index d262effae29ac..b2bc95b50ec28 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; @@ -29,10 +30,33 @@ public function testOnVoterVote() ->onlyMethods(['addVoterVote']) ->getMock(); + $vote = Vote::createGranted(); $traceableAccessDecisionManager ->expects($this->once()) ->method('addVoterVote') - ->with($voter, ['myattr1', 'myattr2'], VoterInterface::ACCESS_GRANTED); + ->with($voter, ['myattr1', 'myattr2'], $vote); + + $sut = new VoteListener($traceableAccessDecisionManager); + $sut->onVoterVote(new VoteEvent($voter, 'mysubject', ['myattr1', 'myattr2'], $vote)); + } + + /** + * @group legacy + */ + public function testOnVoterVoteLegacy() + { + $voter = $this->createMock(VoterInterface::class); + + $traceableAccessDecisionManager = $this + ->getMockBuilder(TraceableAccessDecisionManager::class) + ->disableOriginalConstructor() + ->onlyMethods(['addVoterVote']) + ->getMock(); + + $traceableAccessDecisionManager + ->expects($this->once()) + ->method('addVoterVote') + ->with($voter, ['myattr1', 'myattr2'], Vote::createGranted()); $sut = new VoteListener($traceableAccessDecisionManager); $sut->onVoterVote(new VoteEvent($voter, 'mysubject', ['myattr1', 'myattr2'], VoterInterface::ACCESS_GRANTED)); diff --git a/src/Symfony/Bundle/SecurityBundle/composer.json b/src/Symfony/Bundle/SecurityBundle/composer.json index 0ae91f9cfb023..6d24d503e980d 100644 --- a/src/Symfony/Bundle/SecurityBundle/composer.json +++ b/src/Symfony/Bundle/SecurityBundle/composer.json @@ -64,6 +64,7 @@ "symfony/framework-bundle": "<6.4", "symfony/http-client": "<6.4", "symfony/ldap": "<6.4", + "symfony/security-core": "<7.1", "symfony/serializer": "<6.4", "symfony/twig-bundle": "<6.4", "symfony/validator": "<6.4" 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..dc53d7467e8b0 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php @@ -0,0 +1,108 @@ + + * + * 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 + */ +final class AccessDecision +{ + /** + * @param int $access One of the VoterInterface::ACCESS_* constants + * @param Vote[] $votes + */ + private function __construct(private readonly int $access, private readonly array $votes = []) + { + } + + 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; + } + + /** + * @param Vote[] $votes + */ + public static function createGranted(array $votes = []): self + { + return new self(VoterInterface::ACCESS_GRANTED, $votes); + } + + /** + * @param Vote[] $votes + */ + public static function createDenied(array $votes = []): self + { + return new self(VoterInterface::ACCESS_DENIED, $votes); + } + + /** + * @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 4a56f943a262d..8705b6728f8b0 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 iterable $voters; private array $votersCacheAttributes = []; private array $votersCacheObject = []; @@ -46,11 +41,35 @@ public function __construct(iterable $voters = [], ?AccessDecisionStrategyInterf $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 = $this->strategy->decide( + $this->collectResults($token, $attributes, $object) + ) ? AccessDecision::createGranted() : AccessDecision::createDenied(); + } + + return $decision; + } + /** * @param bool $allowMultipleAttributes Whether to allow passing multiple values to the $attributes array + * + * @deprecated since Symfony 7.1, use {@see getDecision()} instead. */ public function decide(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false): bool { + trigger_deprecation('symfony/security-core', '7.1', 'Method "%s::decide()" has been deprecated, use "%s::getDecision()" instead.', __CLASS__, __CLASS__); + // 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__)); @@ -62,17 +81,33 @@ 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 { + $result = $voter->vote($token, $object, $attributes); + yield match ($result) { + VoterInterface::ACCESS_GRANTED => Vote::createGranted(), + VoterInterface::ACCESS_DENIED => Vote::createDenied(), + VoterInterface::ACCESS_ABSTAIN => Vote::createAbstain(), + default => 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))), + }; } + } + } - 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..c2103fc6e55e0 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 { @@ -25,6 +27,8 @@ interface AccessDecisionManagerInterface * * @param array $attributes An array of attributes associated with the method being invoked * @param mixed $object The object to secure + * + * @deprecated since Symfony 7.1, use {@see getDecision()} instead. */ public function decide(TokenInterface $token, array $attributes, mixed $object = null): bool; } diff --git a/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php b/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php index c748697c494f9..7f77c7ba3413f 100644 --- a/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php +++ b/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php @@ -31,6 +31,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 +43,12 @@ 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')) { + trigger_deprecation('symfony/security-core', '7.1', 'Not implementing "%s::getDecision()" method is deprecated, and would be required in 7.0.', \get_class($this->accessDecisionManager)); + + return $this->accessDecisionManager->decide($token, [$attribute], $subject) ? AccessDecision::createGranted() : AccessDecision::createDenied(); + } + + 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..619aacc625ed8 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Strategy/AccessDecisionStrategyInterface.php +++ b/src/Symfony/Component/Security/Core/Authorization/Strategy/AccessDecisionStrategyInterface.php @@ -11,15 +11,21 @@ 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 { /** * @param \Traversable $results + * + * @deprecated since Symfony 7.1, use {@see getDecision()} instead. */ public function decide(\Traversable $results): bool; } diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/AffirmativeStrategy.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/AffirmativeStrategy.php index ecd74b208616c..e66db39a1b082 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; /** @@ -21,6 +23,7 @@ * * @author Fabien Potencier * @author Alexander M. Turek + * @author Antoine Lamirault */ final class AffirmativeStrategy implements AccessDecisionStrategyInterface, \Stringable { @@ -31,8 +34,37 @@ public function __construct(bool $allowIfAllAbstainDecisions = false) $this->allowIfAllAbstainDecisions = $allowIfAllAbstainDecisions; } + public function getDecision(\Traversable $votes): AccessDecision + { + $currentVotes = []; + $deny = 0; + + /** @var Vote $vote */ + foreach ($votes as $vote) { + $currentVotes[] = $vote; + if ($vote->isGranted()) { + return AccessDecision::createGranted($currentVotes); + } + + if ($vote->isDenied()) { + ++$deny; + } + } + + if ($deny > 0) { + return AccessDecision::createDenied($currentVotes); + } + + return $this->allowIfAllAbstainDecisions + ? AccessDecision::createGranted($currentVotes) + : AccessDecision::createDenied($currentVotes) + ; + } + public function decide(\Traversable $results): bool { + trigger_deprecation('symfony/security-core', '7.1', 'Method "%s::decide()" has been deprecated, use "%s::getDecision()" instead.', __CLASS__, __CLASS__); + $deny = 0; foreach ($results as $result) { if (VoterInterface::ACCESS_GRANTED === $result) { diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/ConsensusStrategy.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/ConsensusStrategy.php index 489b34287b520..6ec8f636e4596 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; /** @@ -29,6 +31,7 @@ * * @author Fabien Potencier * @author Alexander M. Turek + * @author Antoine Lamirault */ final class ConsensusStrategy implements AccessDecisionStrategyInterface, \Stringable { @@ -41,8 +44,47 @@ public function __construct(bool $allowIfAllAbstainDecisions = false, bool $allo $this->allowIfEqualGrantedDeniedDecisions = $allowIfEqualGrantedDeniedDecisions; } + public function getDecision(\Traversable $votes): AccessDecision + { + $currentVotes = []; + + /** @var Vote $vote */ + $grant = 0; + $deny = 0; + foreach ($votes as $vote) { + $currentVotes[] = $vote; + if ($vote->isGranted()) { + ++$grant; + } elseif ($vote->isDenied()) { + ++$deny; + } + } + + if ($grant > $deny) { + return AccessDecision::createGranted($currentVotes); + } + + if ($deny > $grant) { + return AccessDecision::createDenied($currentVotes); + } + + if ($grant > 0) { + return $this->allowIfEqualGrantedDeniedDecisions + ? AccessDecision::createGranted($currentVotes) + : AccessDecision::createDenied($currentVotes) + ; + } + + return $this->allowIfAllAbstainDecisions + ? AccessDecision::createGranted($currentVotes) + : AccessDecision::createDenied($currentVotes) + ; + } + public function decide(\Traversable $results): bool { + trigger_deprecation('symfony/security-core', '7.1', 'Method "%s::decide()" has been deprecated, use "%s::getDecision()" instead.', __CLASS__, __CLASS__); + $grant = 0; $deny = 0; foreach ($results as $result) { diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/PriorityStrategy.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/PriorityStrategy.php index 9599950c7fdbb..61d0ecd868728 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; /** @@ -22,6 +24,7 @@ * * @author Fabien Potencier * @author Alexander M. Turek + * @author Antoine Lamirault */ final class PriorityStrategy implements AccessDecisionStrategyInterface, \Stringable { @@ -32,8 +35,32 @@ public function __construct(bool $allowIfAllAbstainDecisions = false) $this->allowIfAllAbstainDecisions = $allowIfAllAbstainDecisions; } + public function getDecision(\Traversable $votes): AccessDecision + { + $currentVotes = []; + + /** @var Vote $vote */ + foreach ($votes as $vote) { + $currentVotes[] = $vote; + if ($vote->isGranted()) { + return AccessDecision::createGranted($currentVotes); + } + + if ($vote->isDenied()) { + return AccessDecision::createDenied($currentVotes); + } + } + + return $this->allowIfAllAbstainDecisions + ? AccessDecision::createGranted($currentVotes) + : AccessDecision::createDenied($currentVotes) + ; + } + public function decide(\Traversable $results): bool { + trigger_deprecation('symfony/security-core', '7.1', 'Method "%s::decide()" has been deprecated, use "%s::getDecision()" instead.', __CLASS__, __CLASS__); + foreach ($results as $result) { if (VoterInterface::ACCESS_GRANTED === $result) { return true; diff --git a/src/Symfony/Component/Security/Core/Authorization/Strategy/UnanimousStrategy.php b/src/Symfony/Component/Security/Core/Authorization/Strategy/UnanimousStrategy.php index 1f3b85c548fbf..c5b41bdbe12e5 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; /** @@ -21,6 +23,7 @@ * * @author Fabien Potencier * @author Alexander M. Turek + * @author Antoine Lamirault */ final class UnanimousStrategy implements AccessDecisionStrategyInterface, \Stringable { @@ -31,8 +34,38 @@ public function __construct(bool $allowIfAllAbstainDecisions = false) $this->allowIfAllAbstainDecisions = $allowIfAllAbstainDecisions; } + public function getDecision(\Traversable $votes): AccessDecision + { + $currentVotes = []; + $grant = 0; + + /** @var Vote $vote */ + foreach ($votes as $vote) { + $currentVotes[] = $vote; + if ($vote->isDenied()) { + return AccessDecision::createDenied($currentVotes); + } + + if ($vote->isGranted()) { + ++$grant; + } + } + + // no deny votes + if ($grant > 0) { + return AccessDecision::createGranted($currentVotes); + } + + return $this->allowIfAllAbstainDecisions + ? AccessDecision::createGranted($currentVotes) + : AccessDecision::createDenied($currentVotes) + ; + } + public function decide(\Traversable $results): bool { + trigger_deprecation('symfony/security-core', '7.1', 'Method "%s::decide()" has been deprecated, use "%s::getDecision()" instead.', __CLASS__, __CLASS__); + $grant = 0; foreach ($results as $result) { if (VoterInterface::ACCESS_DENIED === $result) { diff --git a/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php b/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php index cb44dce4c0f0d..db70327955c2b 100644 --- a/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php +++ b/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php @@ -13,6 +13,7 @@ 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\VoterInterface; /** @@ -47,8 +48,29 @@ public function __construct(AccessDecisionManagerInterface $manager) } } + 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 { + trigger_deprecation('symfony/security-core', '7.1', 'Method "%s::decide()" has been deprecated, use "%s::getDecision()" instead.', __CLASS__, __CLASS__); + $currentDecisionLog = [ 'attributes' => $attributes, 'object' => $object, @@ -69,11 +91,16 @@ 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 Vote|int $vote vote of the voter */ - public function addVoterVote(VoterInterface $voter, array $attributes, int $vote): void + public function addVoterVote(VoterInterface $voter, array $attributes, Vote|int $vote): void { + if (!$vote instanceof Vote) { + trigger_deprecation('symfony/security-core', '7.1', 'Passing an int as the third argument to "%s::addVoterVote()" is deprecated, pass an instance of "%s" instead.', __CLASS__, Vote::class); + $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 d7b2b22431b04..39d4e145ee704 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/AuthenticatedVoter.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/AuthenticatedVoter.php @@ -40,13 +40,13 @@ public function __construct(AuthenticationTrustResolverInterface $authentication $this->authenticationTrustResolver = $authenticationTrustResolver; } - public function vote(TokenInterface $token, mixed $subject, array $attributes): int + public function getVote(TokenInterface $token, mixed $subject, array $attributes): Vote { if ($attributes === [self::PUBLIC_ACCESS]) { - return VoterInterface::ACCESS_GRANTED; + return Vote::createGranted(); } - $result = VoterInterface::ACCESS_ABSTAIN; + $result = Vote::createAbstain(); foreach ($attributes as $attribute) { if (null === $attribute || (self::IS_AUTHENTICATED_FULLY !== $attribute && self::IS_AUTHENTICATED_REMEMBERED !== $attribute @@ -56,35 +56,42 @@ public function vote(TokenInterface $token, mixed $subject, array $attributes): continue; } - $result = VoterInterface::ACCESS_DENIED; + $result = Vote::createDenied(); if (self::IS_AUTHENTICATED_FULLY === $attribute && $this->authenticationTrustResolver->isFullFledged($token)) { - return VoterInterface::ACCESS_GRANTED; + return Vote::createGranted(); } if (self::IS_AUTHENTICATED_REMEMBERED === $attribute && ($this->authenticationTrustResolver->isRememberMe($token) || $this->authenticationTrustResolver->isFullFledged($token))) { - return VoterInterface::ACCESS_GRANTED; + return Vote::createGranted(); } if (self::IS_AUTHENTICATED === $attribute && $this->authenticationTrustResolver->isAuthenticated($token)) { - return VoterInterface::ACCESS_GRANTED; + return Vote::createGranted(); } if (self::IS_REMEMBERED === $attribute && $this->authenticationTrustResolver->isRememberMe($token)) { - return VoterInterface::ACCESS_GRANTED; + return Vote::createGranted(); } if (self::IS_IMPERSONATOR === $attribute && $token instanceof SwitchUserToken) { - return VoterInterface::ACCESS_GRANTED; + return Vote::createGranted(); } } return $result; } + public function vote(TokenInterface $token, mixed $subject, array $attributes): int + { + trigger_deprecation('symfony/security-core', '7.1', 'Method "%s::vote()" has been deprecated, use "%s::getVote()" instead.', __CLASS__, __CLASS__); + + return $this->getVote($token, $subject, $attributes)->getAccess(); + } + public function supportsAttribute(string $attribute): bool { return \in_array($attribute, [ diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php index 6de9c95465d0a..b53eb6a35969e 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php @@ -49,9 +49,9 @@ public function supportsType(string $subjectType): bool return true; } - public function vote(TokenInterface $token, mixed $subject, array $attributes): int + public function getVote(TokenInterface $token, mixed $subject, array $attributes): Vote { - $result = VoterInterface::ACCESS_ABSTAIN; + $result = Vote::createAbstain(); $variables = null; foreach ($attributes as $attribute) { if (!$attribute instanceof Expression) { @@ -60,15 +60,22 @@ public function vote(TokenInterface $token, mixed $subject, array $attributes): $variables ??= $this->getVariables($token, $subject); - $result = VoterInterface::ACCESS_DENIED; + $result = Vote::createDenied(); if ($this->expressionLanguage->evaluate($attribute, $variables)) { - return VoterInterface::ACCESS_GRANTED; + return Vote::createGranted(); } } return $result; } + public function vote(TokenInterface $token, mixed $subject, array $attributes): int + { + trigger_deprecation('symfony/security-core', '7.1', 'Method "%s::vote()" has been deprecated, use "%s::getVote()" instead.', __CLASS__, __CLASS__); + + return $this->getVote($token, $subject, $attributes)->getAccess(); + } + private function getVariables(TokenInterface $token, mixed $subject): array { $roleNames = $token->getRoleNames(); diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/RoleVoter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/RoleVoter.php index 76de3a32c7f3a..bcecec2e751f0 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/RoleVoter.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/RoleVoter.php @@ -27,9 +27,9 @@ public function __construct(string $prefix = 'ROLE_') $this->prefix = $prefix; } - public function vote(TokenInterface $token, mixed $subject, array $attributes): int + public function getVote(TokenInterface $token, mixed $subject, array $attributes): Vote { - $result = VoterInterface::ACCESS_ABSTAIN; + $result = Vote::createAbstain(); $roles = $this->extractRoles($token); foreach ($attributes as $attribute) { @@ -37,15 +37,22 @@ public function vote(TokenInterface $token, mixed $subject, array $attributes): continue; } - $result = VoterInterface::ACCESS_DENIED; + $result = Vote::createDenied(); if (\in_array($attribute, $roles, true)) { - return VoterInterface::ACCESS_GRANTED; + return Vote::createGranted(); } } return $result; } + public function vote(TokenInterface $token, mixed $subject, array $attributes): int + { + trigger_deprecation('symfony/security-core', '7.1', 'Method "%s::vote()" has been deprecated, use "%s::getVote()" instead.', __CLASS__, __CLASS__); + + return $this->getVote($token, $subject, $attributes)->getAccess(); + } + public function supportsAttribute(string $attribute): bool { return str_starts_with($attribute, $this->prefix); diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/TraceableVoter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/TraceableVoter.php index 412bb9760bfec..fa43b292f182f 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/TraceableVoter.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/TraceableVoter.php @@ -33,13 +33,25 @@ public function __construct(VoterInterface $voter, EventDispatcherInterface $eve $this->eventDispatcher = $eventDispatcher; } - public function vote(TokenInterface $token, mixed $subject, array $attributes): int + public function getVote(TokenInterface $token, mixed $subject, array $attributes): Vote { - $result = $this->voter->vote($token, $subject, $attributes); + if (method_exists($this->voter, 'getVote')) { + $vote = $this->voter->getVote($token, $subject, $attributes); + } else { + $result = $this->voter->vote($token, $subject, $attributes); + $vote = new Vote($result); + } + + $this->eventDispatcher->dispatch(new VoteEvent($this->voter, $subject, $attributes, $vote), 'debug.security.authorization.vote'); + + return $vote; + } - $this->eventDispatcher->dispatch(new VoteEvent($this->voter, $subject, $attributes, $result), 'debug.security.authorization.vote'); + public function vote(TokenInterface $token, mixed $subject, array $attributes): int + { + trigger_deprecation('symfony/security-core', '7.1', 'Method "%s::vote()" has been deprecated, use "%s::getVote()" instead.', __CLASS__, __CLASS__); - return $result; + return $this->getVote($token, $subject, $attributes)->getAccess(); } 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..378dbca6f0463 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php @@ -0,0 +1,134 @@ + + * + * 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 + */ +final class Vote +{ + /** + * @var int One of the VoterInterface::ACCESS_* constants + */ + private int $access; + + /** + * @var string[] + */ + private array $messages; + + private array $context; + + /** + * @param int $access One of the VoterInterface::ACCESS_* constants + */ + public function __construct(int $access, string|array $messages = [], array $context = []) + { + $this->access = $access; + $this->setMessages($messages); + $this->context = $context; + } + + 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; + } + + /** + * @param string|string[] $messages + */ + public static function create(int $access, string|array $messages = [], array $context = []): self + { + return new self($access, $messages, $context); + } + + /** + * @param string|string[] $messages + */ + public static function createGranted(string|array $messages = [], array $context = []): self + { + return new self(VoterInterface::ACCESS_GRANTED, $messages, $context); + } + + /** + * @param string|string[] $messages + */ + public static function createAbstain(string|array $messages = [], array $context = []): self + { + return new self(VoterInterface::ACCESS_ABSTAIN, $messages, $context); + } + + /** + * @param string|string[] $messages + */ + public static function createDenied(string|array $messages = [], array $context = []): self + { + return new self(VoterInterface::ACCESS_DENIED, $messages, $context); + } + + /** + * @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 getContext(): array + { + return $this->context; + } +} diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php index 1f76a42eaf1b8..8364ceddfc751 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): Vote { // abstain vote by default in case none of the attributes are supported - $vote = self::ACCESS_ABSTAIN; + $vote = $this->abstain(); foreach ($attributes as $attribute) { try { @@ -43,17 +43,66 @@ public function vote(TokenInterface $token, mixed $subject, array $attributes): } // as soon as at least one attribute is supported, default is to deny access - $vote = self::ACCESS_DENIED; + if (!$vote->isDenied()) { + $vote = $this->deny(); + } + + $decision = $this->voteOnAttribute($attribute, $subject, $token); + if (\is_bool($decision)) { + trigger_deprecation('symfony/security-core', '7.1', 'Returning a boolean in "%s::voteOnAttribute()" is deprecated, return an instance of "%s" instead.', static::class, Vote::class); + $decision = $decision ? $this->grant() : $this->deny(); + } - 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 + { + trigger_deprecation('symfony/security-core', '7.1', 'Method "%s::vote()" has been deprecated, use "%s::getVote()" instead.', __CLASS__, __CLASS__); + + return $this->getVote($token, $subject, $attributes)->getAccess(); + } + + /** + * Creates a granted vote. + * + * @param string|string[] $messages + */ + protected function grant(string|array $messages = [], array $context = []): Vote + { + return Vote::createGranted($messages, $context); + } + + /** + * Creates an abstained vote. + * + * @param string|string[] $messages + */ + protected function abstain(string|array $messages = [], array $context = []): Vote + { + return Vote::createAbstain($messages, $context); + } + + /** + * Creates a denied vote. + * + * @param string|string[] $messages + */ + protected function deny(string|array $messages = [], array $context = []): Vote + { + return Vote::createDenied($messages, $context); + } + /** * 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. @@ -90,6 +139,8 @@ abstract protected function supports(string $attribute, mixed $subject): bool; * * @param TAttribute $attribute * @param TSubject $subject + * + * @return Vote|bool Returning a boolean is deprecated since Symfony 7.1. Return a Vote object instead. */ - abstract protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool; + abstract protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): Vote|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..1daf112f3b921 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 { @@ -34,6 +36,8 @@ interface VoterInterface * @param array $attributes An array of attributes associated with the method being invoked * * @return self::ACCESS_* + * + * @deprecated since Symfony 7.1, use {@see getVote()} instead. */ 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 47b4a21082738..7ecbfc2a5d1de 100644 --- a/src/Symfony/Component/Security/Core/CHANGELOG.md +++ b/src/Symfony/Component/Security/Core/CHANGELOG.md @@ -2,6 +2,20 @@ CHANGELOG ========= +7.1 +--- + + * Add method `getDecision()` to `AccessDecisionStrategyInterface` + * Deprecate `AccessDecisionStrategyInterface::decide()` in favor of `AccessDecisionStrategyInterface::getDecision()` + * Add method `getVote()` to `VoterInterface` + * Deprecate `VoterInterface::vote()` in favor of `AccessDecisionStrategyInterface::getVote()` + * Deprecate returning `bool` from `Voter::voteOnAttribute()` (it must return a `Vote`) + * Add method `getDecision()` to `AccessDecisionManagerInterface` + * Deprecate `AccessDecisionManagerInterface::decide()` in favor of `AccessDecisionManagerInterface::getDecision()` + * Add method `getDecision()` to `AuthorizationCheckerInterface` + * Add methods `setAccessDecision()` and `getAccessDecision()` to `AccessDeniedException` + * Add method `getDecision()` to `Security` + 7.0 --- diff --git a/src/Symfony/Component/Security/Core/Event/VoteEvent.php b/src/Symfony/Component/Security/Core/Event/VoteEvent.php index 1b1d6a336d6a1..cc72ba32edfd7 100644 --- a/src/Symfony/Component/Security/Core/Event/VoteEvent.php +++ b/src/Symfony/Component/Security/Core/Event/VoteEvent.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Security\Core\Event; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Contracts\EventDispatcher\Event; @@ -26,13 +27,19 @@ final class VoteEvent extends Event private VoterInterface $voter; private mixed $subject; private array $attributes; - private int $vote; + private Vote $vote; - public function __construct(VoterInterface $voter, mixed $subject, array $attributes, int $vote) + public function __construct(VoterInterface $voter, mixed $subject, array $attributes, Vote|int $vote) { $this->voter = $voter; $this->subject = $subject; $this->attributes = $attributes; + + if (!$vote instanceof Vote) { + trigger_deprecation('symfony/security-core', '7.1', 'Passing an int as the fourth argument to "%s::__construct" is deprecated, pass a "%s" instance instead.', __CLASS__, Vote::class); + + $vote = new Vote($vote); + } $this->vote = $vote; } @@ -51,7 +58,17 @@ public function getAttributes(): array return $this->attributes; } + /** + * @deprecated since Symfony 7.1, use {@see getVoteDecision()} instead. + */ public function getVote(): int + { + trigger_deprecation('symfony/security-core', '7.1', 'Method "%s::getVote()" has been deprecated, use "%s::getVoteDecision()" instead.', __CLASS__, __CLASS__); + + return $this->vote->getAccess(); + } + + public function getVoteDecision(): Vote { 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..151312350b579 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 ($messages) { + $this->message .= sprintf(' 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 bf2a2b9a15ec3..b6a9db7bdee51 100644 --- a/src/Symfony/Component/Security/Core/Test/AccessDecisionStrategyTestCase.php +++ b/src/Symfony/Component/Security/Core/Test/AccessDecisionStrategyTestCase.php @@ -13,8 +13,10 @@ 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; /** @@ -29,12 +31,27 @@ abstract class AccessDecisionStrategyTestCase extends TestCase * * @param VoterInterface[] $voters */ - final public function testDecide(AccessDecisionStrategyInterface $strategy, array $voters, bool $expected) + final public function testGetDecision(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->assertEquals($expected, $manager->getDecision($token, ['ROLE_FOO'])); + } + + /** + * @group legacy + * + * @dataProvider provideStrategyTests + * + * @param VoterInterface[] $voters + */ + final public function testDecideLegacy(AccessDecisionStrategyInterface $strategy, array $voters, AccessDecision $expected) + { + $token = $this->createMock(TokenInterface::class); + $manager = new AccessDecisionManager($voters, $strategy); + + $this->assertSame($expected->isGranted(), $manager->decide($token, ['ROLE_FOO'])); } /** @@ -75,6 +92,11 @@ 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); + } }; } } diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php index 37848ab7a9ee4..fcfeb1c0ea644 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php @@ -14,9 +14,11 @@ 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; class AccessDecisionManagerTest extends TestCase @@ -34,8 +36,54 @@ public function testVoterCalls() $token = $this->createMock(TokenInterface::class); $voters = [ - $this->getExpectedVoter(VoterInterface::ACCESS_DENIED), - $this->getExpectedVoter(VoterInterface::ACCESS_GRANTED), + $this->getExpectedVoter(Vote::createDenied()), + $this->getExpectedVoter(Vote::createGranted()), + $this->getUnexpectedVoter(), + ]; + + $strategy = new class() implements AccessDecisionStrategyInterface { + public function getDecision(\Traversable $votes): AccessDecision + { + $i = 0; + /** @var Vote $vote */ + 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 AccessDecision::createGranted(); + } + } + + return AccessDecision::createDenied(); + } + + public function decide(\Traversable $results): bool + { + throw new \RuntimeException('Method should not be called'); + } + }; + + $manager = new AccessDecisionManager($voters, $strategy); + + $expectedDecision = AccessDecision::createGranted(); + $this->assertEquals($expectedDecision, $manager->getDecision($token, ['ROLE_FOO'])); + } + + /** + * @group legacy + */ + public function testVoterCallsLegacy() + { + $token = $this->createMock(TokenInterface::class); + + $voters = [ + $this->getExpectedVoterLegacy(VoterInterface::ACCESS_DENIED), + $this->getExpectedVoterLegacy(VoterInterface::ACCESS_GRANTED), $this->getUnexpectedVoter(), ]; @@ -68,7 +116,11 @@ public function decide(\Traversable $results): bool public function testCacheableVoters() { $token = $this->createMock(TokenInterface::class); - $voter = $this->getMockBuilder(CacheableVoterInterface::class)->getMockForAbstractClass(); + $voter = $this + ->getMockBuilder(CacheableVoterInterface::class) + ->onlyMethods(['supportsAttribute', 'supportsType', 'vote']) + ->addMethods(['getVote']) + ->getMock(); $voter ->expects($this->once()) ->method('supportsAttribute') @@ -79,11 +131,46 @@ public function testCacheableVoters() ->method('supportsType') ->with('string') ->willReturn(true); + + $vote = Vote::createGranted(); $voter ->expects($this->once()) - ->method('vote') + ->method('getVote') + ->with($token, 'bar', ['foo']) + ->willReturn($vote); + + $manager = new AccessDecisionManager([$voter]); + + $expectedDecision = AccessDecision::createGranted([$vote]); + $this->assertEquals($expectedDecision, $manager->getDecision($token, ['foo'], 'bar')); + } + + /** + * @group legacy + */ + public function testCacheableVotersLegacy() + { + $token = $this->createMock(TokenInterface::class); + $voter = $this + ->getMockBuilder(CacheableVoterInterface::class) + ->onlyMethods(['supportsAttribute', 'supportsType', 'vote']) + ->addMethods(['getVote']) + ->getMock(); + $voter + ->expects($this->once()) + ->method('supportsAttribute') + ->with('foo') + ->willReturn(true); + $voter + ->expects($this->once()) + ->method('supportsType') + ->with('string') + ->willReturn(true); + $voter + ->expects($this->once()) + ->method('getVote') ->with($token, 'bar', ['foo']) - ->willReturn(VoterInterface::ACCESS_GRANTED); + ->willReturn(Vote::createGranted()); $manager = new AccessDecisionManager([$voter]); $this->assertTrue($manager->decide($token, ['foo'], 'bar')); @@ -92,7 +179,11 @@ public function testCacheableVoters() public function testCacheableVotersIgnoresNonStringAttributes() { $token = $this->createMock(TokenInterface::class); - $voter = $this->getMockBuilder(CacheableVoterInterface::class)->getMockForAbstractClass(); + $voter = $this + ->getMockBuilder(CacheableVoterInterface::class) + ->onlyMethods(['supportsAttribute', 'supportsType', 'vote']) + ->addMethods(['getVote']) + ->getMock(); $voter ->expects($this->never()) ->method('supportsAttribute'); @@ -103,18 +194,22 @@ public function testCacheableVotersIgnoresNonStringAttributes() ->willReturn(true); $voter ->expects($this->once()) - ->method('vote') + ->method('getVote') ->with($token, 'bar', [1337]) - ->willReturn(VoterInterface::ACCESS_GRANTED); + ->willReturn(Vote::createGranted()); $manager = new AccessDecisionManager([$voter]); - $this->assertTrue($manager->decide($token, [1337], 'bar')); + $this->assertTrue($manager->getDecision($token, [1337], 'bar')->isGranted()); } public function testCacheableVotersWithMultipleAttributes() { $token = $this->createMock(TokenInterface::class); - $voter = $this->getMockBuilder(CacheableVoterInterface::class)->getMockForAbstractClass(); + $voter = $this + ->getMockBuilder(CacheableVoterInterface::class) + ->onlyMethods(['supportsAttribute', 'supportsType', 'vote']) + ->addMethods(['getVote']) + ->getMock(); $voter ->expects($this->exactly(2)) ->method('supportsAttribute') @@ -136,18 +231,22 @@ public function testCacheableVotersWithMultipleAttributes() ->willReturn(true); $voter ->expects($this->once()) - ->method('vote') + ->method('getVote') ->with($token, 'bar', ['foo', 'bar']) - ->willReturn(VoterInterface::ACCESS_GRANTED); + ->willReturn(Vote::createGranted()); $manager = new AccessDecisionManager([$voter]); - $this->assertTrue($manager->decide($token, ['foo', 'bar'], 'bar', true)); + $this->assertTrue($manager->getDecision($token, ['foo', 'bar'], 'bar', true)->isGranted()); } public function testCacheableVotersWithEmptyAttributes() { $token = $this->createMock(TokenInterface::class); - $voter = $this->getMockBuilder(CacheableVoterInterface::class)->getMockForAbstractClass(); + $voter = $this + ->getMockBuilder(CacheableVoterInterface::class) + ->onlyMethods(['supportsAttribute', 'supportsType', 'vote']) + ->addMethods(['getVote']) + ->getMock(); $voter ->expects($this->never()) ->method('supportsAttribute'); @@ -158,18 +257,22 @@ public function testCacheableVotersWithEmptyAttributes() ->willReturn(true); $voter ->expects($this->once()) - ->method('vote') + ->method('getVote') ->with($token, 'bar', []) - ->willReturn(VoterInterface::ACCESS_GRANTED); + ->willReturn(Vote::createGranted()); $manager = new AccessDecisionManager([$voter]); - $this->assertTrue($manager->decide($token, [], 'bar')); + $this->assertTrue($manager->getDecision($token, [], 'bar')->isGranted()); } public function testCacheableVotersSupportsMethodsCalledOnce() { $token = $this->createMock(TokenInterface::class); - $voter = $this->getMockBuilder(CacheableVoterInterface::class)->getMockForAbstractClass(); + $voter = $this + ->getMockBuilder(CacheableVoterInterface::class) + ->onlyMethods(['supportsAttribute', 'supportsType', 'vote']) + ->addMethods(['getVote']) + ->getMock(); $voter ->expects($this->once()) ->method('supportsAttribute') @@ -182,19 +285,23 @@ public function testCacheableVotersSupportsMethodsCalledOnce() ->willReturn(true); $voter ->expects($this->exactly(2)) - ->method('vote') + ->method('getVote') ->with($token, 'bar', ['foo']) - ->willReturn(VoterInterface::ACCESS_GRANTED); + ->willReturn(Vote::createGranted()); $manager = new AccessDecisionManager([$voter]); - $this->assertTrue($manager->decide($token, ['foo'], 'bar')); - $this->assertTrue($manager->decide($token, ['foo'], 'bar')); + $this->assertTrue($manager->getDecision($token, ['foo'], 'bar')->isGranted()); + $this->assertTrue($manager->getDecision($token, ['foo'], 'bar')->isGranted()); } public function testCacheableVotersNotCalled() { $token = $this->createMock(TokenInterface::class); - $voter = $this->getMockBuilder(CacheableVoterInterface::class)->getMockForAbstractClass(); + $voter = $this + ->getMockBuilder(CacheableVoterInterface::class) + ->onlyMethods(['supportsAttribute', 'supportsType', 'vote']) + ->addMethods(['getVote']) + ->getMock(); $voter ->expects($this->once()) ->method('supportsAttribute') @@ -205,16 +312,20 @@ public function testCacheableVotersNotCalled() ->method('supportsType'); $voter ->expects($this->never()) - ->method('vote'); + ->method('getVote'); $manager = new AccessDecisionManager([$voter]); - $this->assertFalse($manager->decide($token, ['foo'], 'bar')); + $this->assertFalse($manager->getDecision($token, ['foo'], 'bar')->isGranted()); } public function testCacheableVotersWithMultipleAttributesAndNonString() { $token = $this->createMock(TokenInterface::class); - $voter = $this->getMockBuilder(CacheableVoterInterface::class)->getMockForAbstractClass(); + $voter = $this + ->getMockBuilder(CacheableVoterInterface::class) + ->onlyMethods(['supportsAttribute', 'supportsType', 'vote']) + ->addMethods(['getVote']) + ->getMock(); $voter ->expects($this->once()) ->method('supportsAttribute') @@ -228,48 +339,29 @@ public function testCacheableVotersWithMultipleAttributesAndNonString() ->willReturn(true); $voter ->expects($this->once()) - ->method('vote') + ->method('getVote') ->with($token, 'bar', ['foo', 1337]) - ->willReturn(VoterInterface::ACCESS_GRANTED); + ->willReturn(Vote::createGranted()); $manager = new AccessDecisionManager([$voter]); - $this->assertTrue($manager->decide($token, ['foo', 1337], 'bar', true)); - } - - protected static function getVoters($grants, $denies, $abstains): array - { - $voters = []; - for ($i = 0; $i < $grants; ++$i) { - $voters[] = self::getVoter(VoterInterface::ACCESS_GRANTED); - } - for ($i = 0; $i < $denies; ++$i) { - $voters[] = self::getVoter(VoterInterface::ACCESS_DENIED); - } - for ($i = 0; $i < $abstains; ++$i) { - $voters[] = self::getVoter(VoterInterface::ACCESS_ABSTAIN); - } - - return $voters; + $this->assertTrue($manager->getDecision($token, ['foo', 1337], 'bar', true)->isGranted()); } - protected static function getVoter($vote) + private function getExpectedVoter(Vote $vote): VoterInterface { - return new class($vote) implements VoterInterface { - private int $vote; - - public function __construct(int $vote) - { - $this->vote = $vote; - } + $voter = $this + ->getMockBuilder(VoterInterface::class) + ->onlyMethods(['vote']) + ->addMethods(['getVote']) + ->getMock(); + $voter->expects($this->once()) + ->method('getVote') + ->willReturn($vote); - public function vote(TokenInterface $token, $subject, array $attributes) - { - return $this->vote; - } - }; + return $voter; } - private function getExpectedVoter(int $vote): VoterInterface + private function getExpectedVoterLegacy(int $vote): VoterInterface { $voter = $this->createMock(VoterInterface::class); $voter->expects($this->once()) diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/AuthorizationCheckerTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/AuthorizationCheckerTest.php index 36b048c8976d1..c604be2877551 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/AuthorizationCheckerTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/AuthorizationCheckerTest.php @@ -13,15 +13,19 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; 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\User\InMemoryUser; class AuthorizationCheckerTest extends TestCase { + use ExpectDeprecationTrait; + private MockObject&AccessDecisionManagerInterface $accessDecisionManager; private AuthorizationChecker $authorizationChecker; private TokenStorage $tokenStorage; @@ -35,26 +39,84 @@ protected function setUp(): void } public function testVoteWithoutAuthenticationToken() + { + $accessDecisionManager = $this + ->getMockBuilder(AccessDecisionManagerInterface::class) + ->onlyMethods(['decide']) + ->addMethods(['getDecision']) + ->getMock(); + $accessDecisionManager + ->expects($this->once()) + ->method('getDecision') + ->with($this->isInstanceOf(NullToken::class), ['ROLE_FOO']) + ->willReturn(AccessDecision::createGranted()); + + $authorizationChecker = new AuthorizationChecker( + $this->tokenStorage, + $accessDecisionManager + ); + + $authorizationChecker->isGranted('ROLE_FOO'); + } + + /** + * @group legacy + */ + public function testVoteWithoutAuthenticationTokenLegacy() { $authorizationChecker = new AuthorizationChecker($this->tokenStorage, $this->accessDecisionManager); $this->accessDecisionManager->expects($this->once())->method('decide')->with($this->isInstanceOf(NullToken::class))->willReturn(false); + $this->expectDeprecation('Since symfony/security-core 7.1: Not implementing "%s::getDecision()" method is deprecated, and would be required in 7.0.'); $authorizationChecker->isGranted('ROLE_FOO'); } /** + * @group legacy + * * @dataProvider isGrantedProvider */ public function testIsGranted($decide) { $token = new UsernamePasswordToken(new InMemoryUser('username', 'password', ['ROLE_USER']), 'provider', ['ROLE_USER']); + $accessDecisionManager = $this + ->getMockBuilder(AccessDecisionManagerInterface::class) + ->onlyMethods(['decide']) + ->addMethods(['getDecision']) + ->getMock(); + $accessDecisionManager + ->expects($this->once()) + ->method('getDecision') + ->with($this->identicalTo($token), $this->identicalTo(['ROLE_FOO'])) + ->willReturn($decide ? AccessDecision::createGranted() : AccessDecision::createDenied()); + + $this->tokenStorage->setToken($token); + $authorizationChecker = new AuthorizationChecker( + $this->tokenStorage, + $accessDecisionManager + ); + + $this->assertSame($decide, $authorizationChecker->isGranted('ROLE_FOO')); + } + + /** + * @group legacy + * + * @dataProvider isGrantedProvider + */ + public function testIsGrantedLegacy($decide) + { + $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->expectDeprecation('Since symfony/security-core 7.1: Not implementing "%s::getDecision()" method is deprecated, and would be required in 7.0.'); $this->assertSame($decide, $this->authorizationChecker->isGranted('ROLE_FOO')); } @@ -69,12 +131,43 @@ public function testIsGrantedWithObjectAttribute() $token = new UsernamePasswordToken(new InMemoryUser('username', 'password', ['ROLE_USER']), 'provider', ['ROLE_USER']); + $accessDecisionManager = $this + ->getMockBuilder(AccessDecisionManagerInterface::class) + ->onlyMethods(['decide']) + ->addMethods(['getDecision']) + ->getMock(); + $accessDecisionManager + ->expects($this->once()) + ->method('getDecision') + ->with($this->identicalTo($token), $this->identicalTo([$attribute])) + ->willReturn(AccessDecision::createGranted()); + + $this->tokenStorage->setToken($token); + $authorizationChecker = new AuthorizationChecker( + $this->tokenStorage, + $accessDecisionManager + ); + + $this->assertTrue($authorizationChecker->isGranted($attribute)); + } + + /** + * @group legacy + */ + public function testIsGrantedWithObjectAttributeLegacy() + { + $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->expectDeprecation('Since symfony/security-core 7.1: Not implementing "%s::getDecision()" method is deprecated, and would be required in 7.0.'); $this->assertTrue($this->authorizationChecker->isGranted($attribute)); } } 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..3f89f1e6dd5bd 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/AffirmativeStrategyTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/AffirmativeStrategyTest.php @@ -9,9 +9,11 @@ * file that was distributed with this source code. */ -namespace Authorization\Strategy; +namespace Symfony\Component\Security\Core\Tests\Authorization\Strategy; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\Strategy\AffirmativeStrategy; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Test\AccessDecisionStrategyTestCase; class AffirmativeStrategyTest extends AccessDecisionStrategyTestCase @@ -20,13 +22,23 @@ 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), AccessDecision::createGranted([ + Vote::createGranted(), + ])]; + yield [$strategy, self::getVoters(1, 2, 0), AccessDecision::createGranted([ + Vote::createGranted(), + ])]; + yield [$strategy, self::getVoters(0, 1, 0), AccessDecision::createDenied([ + Vote::createDenied(), + ])]; + yield [$strategy, self::getVoters(0, 0, 1), AccessDecision::createDenied([ + Vote::createAbstain(), + ])]; $strategy = new AffirmativeStrategy(true); - yield [$strategy, self::getVoters(0, 0, 1), true]; + yield [$strategy, self::getVoters(0, 0, 1), AccessDecision::createGranted([ + Vote::createAbstain(), + ])]; } } 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..47eac3e115aa0 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ConsensusStrategyTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/ConsensusStrategyTest.php @@ -9,9 +9,11 @@ * file that was distributed with this source code. */ -namespace Authorization\Strategy; +namespace Symfony\Component\Security\Core\Tests\Authorization\Strategy; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\Strategy\ConsensusStrategy; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Test\AccessDecisionStrategyTestCase; class ConsensusStrategyTest extends AccessDecisionStrategyTestCase @@ -20,21 +22,57 @@ 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), AccessDecision::createGranted([ + Vote::createGranted(), + ])]; + yield [$strategy, self::getVoters(1, 2, 0), AccessDecision::createDenied([ + Vote::createGranted(), + Vote::createDenied(), + Vote::createDenied(), + ])]; + yield [$strategy, self::getVoters(2, 1, 0), AccessDecision::createGranted([ + Vote::createGranted(), + Vote::createGranted(), + Vote::createDenied(), + ])]; + yield [$strategy, self::getVoters(0, 0, 1), AccessDecision::createDenied([ + Vote::createAbstain(), + ])]; - yield [$strategy, self::getVoters(2, 2, 0), true]; - yield [$strategy, self::getVoters(2, 2, 1), true]; + yield [$strategy, self::getVoters(2, 2, 0), AccessDecision::createGranted([ + Vote::createGranted(), + Vote::createGranted(), + Vote::createDenied(), + Vote::createDenied(), + ])]; + yield [$strategy, self::getVoters(2, 2, 1), AccessDecision::createGranted([ + Vote::createGranted(), + Vote::createGranted(), + Vote::createDenied(), + Vote::createDenied(), + Vote::createAbstain(), + ])]; $strategy = new ConsensusStrategy(true); - yield [$strategy, self::getVoters(0, 0, 1), true]; + yield [$strategy, self::getVoters(0, 0, 1), AccessDecision::createGranted([ + Vote::createAbstain(), + ])]; $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), AccessDecision::createDenied([ + Vote::createGranted(), + Vote::createGranted(), + Vote::createDenied(), + Vote::createDenied(), + ])]; + yield [$strategy, self::getVoters(2, 2, 1), AccessDecision::createDenied([ + Vote::createGranted(), + Vote::createGranted(), + Vote::createDenied(), + Vote::createDenied(), + Vote::createAbstain(), + ])]; } } 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..807fb7b0f72bd 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/PriorityStrategyTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/PriorityStrategyTest.php @@ -9,9 +9,11 @@ * file that was distributed with this source code. */ -namespace Authorization\Strategy; +namespace Symfony\Component\Security\Core\Tests\Authorization\Strategy; +use Symfony\Component\Security\Core\Authorization\AccessDecision; 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,19 +28,31 @@ public static function provideStrategyTests(): iterable self::getVoter(VoterInterface::ACCESS_GRANTED), self::getVoter(VoterInterface::ACCESS_DENIED), self::getVoter(VoterInterface::ACCESS_DENIED), - ], true]; + ], AccessDecision::createGranted([ + Vote::createAbstain(), + Vote::createGranted(), + ])]; yield [$strategy, [ self::getVoter(VoterInterface::ACCESS_ABSTAIN), self::getVoter(VoterInterface::ACCESS_DENIED), self::getVoter(VoterInterface::ACCESS_GRANTED), self::getVoter(VoterInterface::ACCESS_GRANTED), - ], false]; + ], AccessDecision::createDenied([ + Vote::createAbstain(), + Vote::createDenied(), + ])]; - yield [$strategy, self::getVoters(0, 0, 2), false]; + yield [$strategy, self::getVoters(0, 0, 2), AccessDecision::createDenied([ + Vote::createAbstain(), + Vote::createAbstain(), + ])]; $strategy = new PriorityStrategy(true); - yield [$strategy, self::getVoters(0, 0, 2), true]; + yield [$strategy, self::getVoters(0, 0, 2), AccessDecision::createGranted([ + Vote::createAbstain(), + Vote::createAbstain(), + ])]; } } 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..f51d142ab98cf 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/UnanimousStrategyTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Strategy/UnanimousStrategyTest.php @@ -9,9 +9,11 @@ * file that was distributed with this source code. */ -namespace Authorization\Strategy; +namespace Symfony\Component\Security\Core\Tests\Authorization\Strategy; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\Strategy\UnanimousStrategy; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Test\AccessDecisionStrategyTestCase; class UnanimousStrategyTest extends AccessDecisionStrategyTestCase @@ -20,14 +22,28 @@ 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), AccessDecision::createGranted([ + Vote::createGranted(), + ])]; + yield [$strategy, self::getVoters(1, 0, 1), AccessDecision::createGranted([ + Vote::createGranted(), + Vote::createAbstain(), + ])]; + yield [$strategy, self::getVoters(1, 1, 0), AccessDecision::createDenied([ + Vote::createGranted(), + Vote::createDenied(), + ])]; + + yield [$strategy, self::getVoters(0, 0, 2), AccessDecision::createDenied([ + Vote::createAbstain(), + Vote::createAbstain(), + ])]; $strategy = new UnanimousStrategy(true); - yield [$strategy, self::getVoters(0, 0, 2), true]; + yield [$strategy, self::getVoters(0, 0, 2), AccessDecision::createGranted([ + Vote::createAbstain(), + Vote::createAbstain(), + ])]; } } diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerTest.php index c14ee1fa459d0..bc1c32be3c141 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerTest.php @@ -13,9 +13,11 @@ 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; @@ -24,28 +26,32 @@ class TraceableAccessDecisionManagerTest extends TestCase /** * @dataProvider provideObjectsAndLogs */ - public function testDecideLog(array $expectedLog, array $attributes, $object, array $voterVotes, bool $result) + public function testDecideLog(array $expectedLog, array $attributes, $object, array $voterVotes, AccessDecision $decision) { $token = $this->createMock(TokenInterface::class); - $admMock = $this->createMock(AccessDecisionManagerInterface::class); + $admMock = $this + ->getMockBuilder(AccessDecisionManagerInterface::class) + ->onlyMethods(['decide']) + ->addMethods(['getDecision']) + ->getMock(); $adm = new TraceableAccessDecisionManager($admMock); $admMock ->expects($this->once()) - ->method('decide') + ->method('getDecision') ->with($token, $attributes, $object) - ->willReturnCallback(function ($token, $attributes, $object) use ($voterVotes, $adm, $result) { + ->willReturnCallback(function ($token, $attributes, $object) use ($voterVotes, $adm, $decision) { foreach ($voterVotes as $voterVote) { [$voter, $vote] = $voterVote; $adm->addVoterVote($voter, $attributes, $vote); } - return $result; + return $decision; }) ; - $adm->decide($token, $attributes, $object); + $adm->getDecision($token, $attributes, $object); $this->assertEquals($expectedLog, $adm->getDecisionLog()); } @@ -59,115 +65,115 @@ public static function provideObjectsAndLogs(): \Generator [[ 'attributes' => ['ATTRIBUTE_1'], 'object' => null, - 'result' => true, + 'result' => ($result = AccessDecision::createGranted()), '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' => $granted_vote1 = Vote::createGranted()], + ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1'], 'vote' => $granted_vote2 = Vote::createGranted()], ], ]], ['ATTRIBUTE_1'], null, [ - [$voter1, VoterInterface::ACCESS_GRANTED], - [$voter2, VoterInterface::ACCESS_GRANTED], + [$voter1, $granted_vote1], + [$voter2, $granted_vote2], ], - true, + $result, ]; yield [ [[ 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'object' => true, - 'result' => false, + 'result' => ($result = AccessDecision::createDenied()), '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' => $abstain_vote1 = Vote::createAbstain()], + ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => $granted_vote2 = Vote::createGranted()], ], ]], ['ATTRIBUTE_1', 'ATTRIBUTE_2'], true, [ - [$voter1, VoterInterface::ACCESS_ABSTAIN], - [$voter2, VoterInterface::ACCESS_GRANTED], + [$voter1, $abstain_vote1], + [$voter2, $granted_vote2], ], - false, + $result, ]; yield [ [[ 'attributes' => [null], 'object' => 'jolie string', - 'result' => false, + 'result' => ($result = AccessDecision::createDenied()), 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => [null], 'vote' => VoterInterface::ACCESS_ABSTAIN], - ['voter' => $voter2, 'attributes' => [null], 'vote' => VoterInterface::ACCESS_DENIED], + ['voter' => $voter1, 'attributes' => [null], 'vote' => $abstain_vote1 = Vote::createAbstain()], + ['voter' => $voter2, 'attributes' => [null], 'vote' => $abstain_denied2 = Vote::createAbstain()], ], ]], [null], 'jolie string', [ - [$voter1, VoterInterface::ACCESS_ABSTAIN], - [$voter2, VoterInterface::ACCESS_DENIED], + [$voter1, $abstain_vote1], + [$voter2, $abstain_denied2], ], - false, + $result, ]; yield [ [[ 'attributes' => [12], 'object' => 12345, - 'result' => true, + 'result' => ($result = AccessDecision::createGranted()), 'voterDetails' => [], ]], 'attributes' => [12], 12345, [], - true, + $result, ]; yield [ [[ 'attributes' => [new \stdClass()], 'object' => $x = fopen(__FILE__, 'r'), - 'result' => true, + 'result' => ($result = AccessDecision::createGranted()), 'voterDetails' => [], ]], [new \stdClass()], $x, [], - true, + $result, ]; yield [ [[ 'attributes' => ['ATTRIBUTE_2'], 'object' => $x = [], - 'result' => false, + 'result' => ($result = AccessDecision::createDenied()), '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' => $abstain_vote1 = Vote::createAbstain()], + ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_2'], 'vote' => $abstain_vote2 = Vote::createAbstain()], ], ]], ['ATTRIBUTE_2'], $x, [ - [$voter1, VoterInterface::ACCESS_ABSTAIN], - [$voter2, VoterInterface::ACCESS_ABSTAIN], + [$voter1, $abstain_vote1], + [$voter2, $abstain_vote2], ], - false, + $result, ]; yield [ [[ 'attributes' => [12.13], 'object' => new \stdClass(), - 'result' => false, + 'result' => ($result = AccessDecision::createDenied()), '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' => $denied_vote1 = Vote::createDenied()], + ['voter' => $voter2, 'attributes' => [12.13], 'vote' => $denied_vote2 = Vote::createDenied()], ], ]], [12.13], new \stdClass(), [ - [$voter1, VoterInterface::ACCESS_DENIED], - [$voter2, VoterInterface::ACCESS_DENIED], + [$voter1, $denied_vote1], + [$voter2, $denied_vote2], ], - false, + $result, ]; } @@ -179,25 +185,28 @@ 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('vote') + ->method('getVote') ->willReturnCallback(function (TokenInterface $token, $subject, array $attributes) use ($sut, $voter1) { - $vote = \in_array('attr1', $attributes, true) ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_ABSTAIN; + $vote = \in_array('attr1', $attributes, true) ? Vote::createGranted() : Vote::createAbstain(); $sut->addVoterVote($voter1, $attributes, $vote); return $vote; @@ -205,12 +214,12 @@ public function testAccessDecisionManagerCalledByVoter() $voter2 ->expects($this->any()) - ->method('vote') + ->method('getVote') ->willReturnCallback(function (TokenInterface $token, $subject, array $attributes) use ($sut, $voter2) { if (\in_array('attr2', $attributes, true)) { - $vote = null == $subject ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED; + $vote = null == $subject ? Vote::createGranted() : Vote::createDenied(); } else { - $vote = VoterInterface::ACCESS_ABSTAIN; + $vote = Vote::createAbstain(); } $sut->addVoterVote($voter2, $attributes, $vote); @@ -220,12 +229,12 @@ public function testAccessDecisionManagerCalledByVoter() $voter3 ->expects($this->any()) - ->method('vote') + ->method('getVote') ->willReturnCallback(function (TokenInterface $token, $subject, array $attributes) use ($sut, $voter3) { if (\in_array('attr2', $attributes, true) && $subject) { - $vote = $sut->decide($token, $attributes) ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED; + $vote = $sut->getDecision($token, $attributes)->isGranted() ? Vote::createGranted() : Vote::createDenied(); } else { - $vote = VoterInterface::ACCESS_ABSTAIN; + $vote = Vote::createAbstain(); } $sut->addVoterVote($voter3, $attributes, $vote); @@ -234,36 +243,45 @@ public function testAccessDecisionManagerCalledByVoter() }); $token = $this->createMock(TokenInterface::class); - $sut->decide($token, ['attr1'], null); - $sut->decide($token, ['attr2'], $obj = new \stdClass()); + $sut->getDecision($token, ['attr1'], null); + $sut->getDecision($token, ['attr2'], $obj = new \stdClass()); $this->assertEquals([ [ 'attributes' => ['attr1'], 'object' => null, 'voterDetails' => [ - ['voter' => $voter1, 'attributes' => ['attr1'], 'vote' => VoterInterface::ACCESS_GRANTED], + ['voter' => $voter1, 'attributes' => ['attr1'], 'vote' => Vote::createGranted()], ], - 'result' => true, + 'result' => AccessDecision::createGranted([ + Vote::createGranted(), + ]), ], [ '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' => Vote::createAbstain()], + ['voter' => $voter2, 'attributes' => ['attr2'], 'vote' => Vote::createGranted()], ], - 'result' => true, + 'result' => AccessDecision::createGranted([ + Vote::createAbstain(), + Vote::createGranted(), + ]), ], [ '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' => Vote::createAbstain()], + ['voter' => $voter2, 'attributes' => ['attr2'], 'vote' => Vote::createDenied()], + ['voter' => $voter3, 'attributes' => ['attr2'], 'vote' => Vote::createGranted()], ], - 'result' => true, + 'result' => AccessDecision::createGranted([ + Vote::createAbstain(), + Vote::createDenied(), + Vote::createGranted(), + ]), ], ], $sut->getDecisionLog()); } 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 88544c081f78c..b9a840fb6ce71 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/AuthenticatedVoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/AuthenticatedVoterTest.php @@ -12,28 +12,65 @@ namespace Symfony\Component\Security\Core\Tests\Authorization\Voter; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolver; use Symfony\Component\Security\Core\Authentication\Token\AbstractToken; use Symfony\Component\Security\Core\Authentication\Token\NullToken; use Symfony\Component\Security\Core\Authentication\Token\RememberMeToken; use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken; use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\User\InMemoryUser; class AuthenticatedVoterTest extends TestCase { + use ExpectDeprecationTrait; + + /** + * @dataProvider getGetVoteTests + */ + public function testGetVote($authenticated, $attributes, $expected) + { + $voter = new AuthenticatedVoter(new AuthenticationTrustResolver()); + + $this->assertEquals($expected, $voter->getVote($this->getToken($authenticated), null, $attributes)); + } + + public static function getGetVoteTests() + { + return [ + ['fully', [], Vote::createAbstain()], + ['fully', ['FOO'], Vote::createAbstain()], + ['remembered', [], Vote::createAbstain()], + ['remembered', ['FOO'], Vote::createAbstain()], + + ['fully', ['IS_AUTHENTICATED_REMEMBERED'], Vote::createGranted()], + ['remembered', ['IS_AUTHENTICATED_REMEMBERED'], Vote::createGranted()], + + ['fully', ['IS_AUTHENTICATED_FULLY'], Vote::createGranted()], + ['remembered', ['IS_AUTHENTICATED_FULLY'], Vote::createDenied()], + + ['fully', ['IS_IMPERSONATOR'], Vote::createDenied()], + ['remembered', ['IS_IMPERSONATOR'], Vote::createDenied()], + ['impersonated', ['IS_IMPERSONATOR'], Vote::createGranted()], + ]; + } + /** - * @dataProvider getVoteTests + * @group legacy + * + * @dataProvider getVoteTestsLegacy */ - public function testVote($authenticated, $attributes, $expected) + public function testVoteLegacy($authenticated, $attributes, $expected) { $voter = new AuthenticatedVoter(new AuthenticationTrustResolver()); + $this->expectDeprecation('Since symfony/security-core 7.1: Method "%s::vote()" has been deprecated, use "%s::getVote()" instead.'); $this->assertSame($expected, $voter->vote($this->getToken($authenticated), null, $attributes)); } - public static function getVoteTests() + public static function getVoteTestsLegacy() { return [ ['fully', [], VoterInterface::ACCESS_ABSTAIN], 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..aaa7dfb4ca364 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/ExpressionVoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/ExpressionVoterTest.php @@ -12,27 +12,57 @@ namespace Symfony\Component\Security\Core\Tests\Authorization\Voter; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface; use Symfony\Component\Security\Core\Authentication\Token\AbstractToken; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Authorization\ExpressionLanguage; use Symfony\Component\Security\Core\Authorization\Voter\ExpressionVoter; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; class ExpressionVoterTest extends TestCase { + use ExpectDeprecationTrait; + + /** + * @dataProvider getGetVoteTests + */ + public function testGetVoteWithTokenThatReturnsRoleNames(array $roles, array $attributes, Vote $expected, $tokenExpectsGetRoles = true, $expressionLanguageExpectsEvaluate = true) + { + $voter = new ExpressionVoter($this->createExpressionLanguage($expressionLanguageExpectsEvaluate), $this->createTrustResolver(), $this->createAuthorizationChecker()); + + $this->assertEquals($expected, $voter->getVote($this->getTokenWithRoleNames($roles, $tokenExpectsGetRoles), null, $attributes)); + } + + public function getGetVoteTests() + { + return [ + [[], [], Vote::createAbstain(), false, false], + [[], ['FOO'], Vote::createAbstain(), false, false], + + [[], [$this->createExpression()], Vote::createDenied(), true, false], + + [['ROLE_FOO'], [$this->createExpression(), $this->createExpression()], Vote::createGranted()], + [['ROLE_BAR', 'ROLE_FOO'], [$this->createExpression()], Vote::createGranted()], + ]; + } + /** - * @dataProvider getVoteTests + * @group legacy + * + * @dataProvider getVoteTestsLegacy */ - public function testVoteWithTokenThatReturnsRoleNames($roles, $attributes, $expected, $tokenExpectsGetRoles = true, $expressionLanguageExpectsEvaluate = true) + public function testVoteWithTokenThatReturnsRoleNamesLegacy($roles, $attributes, $expected, $tokenExpectsGetRoles = true, $expressionLanguageExpectsEvaluate = true) { $voter = new ExpressionVoter($this->createExpressionLanguage($expressionLanguageExpectsEvaluate), $this->createTrustResolver(), $this->createAuthorizationChecker()); + $this->expectDeprecation('Since symfony/security-core 7.1: Method "%s::vote()" has been deprecated, use "%s::getVote()" instead.'); $this->assertSame($expected, $voter->vote($this->getTokenWithRoleNames($roles, $tokenExpectsGetRoles), null, $attributes)); } - public static function getVoteTests() + public static function getVoteTestsLegacy() { return [ [[], [], VoterInterface::ACCESS_ABSTAIN, false, false], 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..ffdf14352be2d 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleHierarchyVoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleHierarchyVoterTest.php @@ -12,19 +12,39 @@ namespace Symfony\Component\Security\Core\Tests\Authorization\Voter; use Symfony\Component\Security\Core\Authorization\Voter\RoleHierarchyVoter; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Role\RoleHierarchy; class RoleHierarchyVoterTest extends RoleVoterTest { /** + * @dataProvider getGetVoteTests + */ + public function testGetVoteUsingTokenThatReturnsRoleNames($roles, $attributes, $expected) + { + $voter = new RoleHierarchyVoter(new RoleHierarchy(['ROLE_FOO' => ['ROLE_FOOBAR']])); + + $this->assertEquals($expected, $voter->getVote($this->getTokenWithRoleNames($roles), null, $attributes)); + } + + public function getGetVoteTests() + { + return array_merge(parent::getGetVoteTests(), [ + [['ROLE_FOO'], ['ROLE_FOOBAR'], Vote::createGranted()], + ]); + } + + /** + * @group legacy + * * @dataProvider getVoteTests */ - public function testVoteUsingTokenThatReturnsRoleNames($roles, $attributes, $expected) + public function testVoteUsingTokenThatReturnsRoleNamesLegacy($roles, $attributes, $expected) { $voter = new RoleHierarchyVoter(new RoleHierarchy(['ROLE_FOO' => ['ROLE_FOOBAR']])); - $this->assertSame($expected, $voter->vote($this->getTokenWithRoleNames($roles), null, $attributes)); + $this->assertEquals($expected, $voter->vote($this->getTokenWithRoleNames($roles), null, $attributes)); } public static function getVoteTests() @@ -41,7 +61,7 @@ public function testVoteWithEmptyHierarchyUsingTokenThatReturnsRoleNames($roles, { $voter = new RoleHierarchyVoter(new RoleHierarchy([])); - $this->assertSame($expected, $voter->vote($this->getTokenWithRoleNames($roles), null, $attributes)); + $this->assertEquals($expected, $voter->getVote($this->getTokenWithRoleNames($roles), null, $attributes)->getAccess()); } public static function getVoteWithEmptyHierarchyTests() 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..3eb66d014c26f 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleVoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/RoleVoterTest.php @@ -12,21 +12,54 @@ namespace Symfony\Component\Security\Core\Tests\Authorization\Voter; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolver; use Symfony\Component\Security\Core\Authentication\Token\AbstractToken; use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter; use Symfony\Component\Security\Core\Authorization\Voter\RoleVoter; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; class RoleVoterTest extends TestCase { + use ExpectDeprecationTrait; + + /** + * @dataProvider getGetVoteTests + */ + public function testGetVoteUsingTokenThatReturnsRoleNames($roles, $attributes, $expected) + { + $voter = new RoleVoter(); + + $this->assertEquals($expected, $voter->getVote($this->getTokenWithRoleNames($roles), null, $attributes)); + } + + public function getGetVoteTests() + { + return [ + [[], [], Vote::createAbstain()], + [[], ['FOO'], Vote::createAbstain()], + [[], ['ROLE_FOO'], Vote::createDenied()], + [['ROLE_FOO'], ['ROLE_FOO'], Vote::createGranted()], + [['ROLE_FOO'], ['FOO', 'ROLE_FOO'], Vote::createGranted()], + [['ROLE_BAR', 'ROLE_FOO'], ['ROLE_FOO'], Vote::createGranted()], + + // Test mixed Types + [[], [[]], Vote::createAbstain()], + [[], [new \stdClass()], Vote::createAbstain()], + ]; + } + /** + * @group legacy + * * @dataProvider getVoteTests */ - public function testVoteUsingTokenThatReturnsRoleNames($roles, $attributes, $expected) + public function testVoteUsingTokenThatReturnsRoleNamesLegacy($roles, $attributes, $expected) { $voter = new RoleVoter(); + $this->expectDeprecation('Since symfony/security-core 7.1: Method "%s::vote()" has been deprecated, use "%s::getVote()" instead.'); $this->assertSame($expected, $voter->vote($this->getTokenWithRoleNames($roles), null, $attributes)); } 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 d0f8ae08f97db..996e718bad554 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/TraceableVoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/TraceableVoterTest.php @@ -12,15 +12,19 @@ namespace Symfony\Component\Security\Core\Tests\Authorization\Voter; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; 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\Contracts\EventDispatcher\EventDispatcherInterface; class TraceableVoterTest extends TestCase { + use ExpectDeprecationTrait; + public function testGetDecoratedVoterClass() { $voter = $this->getMockBuilder(VoterInterface::class)->getMockForAbstractClass(); @@ -30,6 +34,39 @@ public function testGetDecoratedVoterClass() } public function testVote() + { + $voter = $this + ->getMockBuilder(VoterInterface::class) + ->onlyMethods(['vote']) + ->addMethods(['getVote']) + ->getMock(); + + $eventDispatcher = $this->getMockBuilder(EventDispatcherInterface::class)->getMockForAbstractClass(); + $token = $this->getMockBuilder(TokenInterface::class)->getMockForAbstractClass(); + + $vote = Vote::createDenied(); + + $voter + ->expects($this->once()) + ->method('getVote') + ->with($token, 'anysubject', ['attr1']) + ->willReturn($vote); + + $eventDispatcher + ->expects($this->once()) + ->method('dispatch') + ->with(new VoteEvent($voter, 'anysubject', ['attr1'], $vote), 'debug.security.authorization.vote'); + + $sut = new TraceableVoter($voter, $eventDispatcher); + $result = $sut->getVote($token, 'anysubject', ['attr1']); + + $this->assertSame($vote, $result); + } + + /** + * @group legacy + */ + public function testVoteLegacy() { $voter = $this->getMockBuilder(VoterInterface::class)->getMockForAbstractClass(); @@ -48,6 +85,8 @@ public function testVote() ->with(new VoteEvent($voter, 'anysubject', ['attr1'], VoterInterface::ACCESS_DENIED), 'debug.security.authorization.vote'); $sut = new TraceableVoter($voter, $eventDispatcher); + + $this->expectDeprecation('Since symfony/security-core 7.1: Method "%s::vote()" has been deprecated, use "%s::getVote()" instead.'); $result = $sut->vote($token, 'anysubject', ['attr1']); $this->assertSame(VoterInterface::ACCESS_DENIED, $result); diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoteTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoteTest.php new file mode 100644 index 0000000000000..96bdbbfa0df80 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoteTest.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\Tests\Authorization\Voter; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; +use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; +use Symfony\Component\Security\Core\Exception\InvalidArgumentException; + +class VoteTest extends TestCase +{ + public function testMessages() + { + $vote = new Vote(VoterInterface::ACCESS_GRANTED, 'foo'); + + $this->assertSame('foo', $vote->getMessage()); + + $vote->addMessage('bar'); + $this->assertSame('foo, bar', $vote->getMessage()); + } + + public function testMessagesWithNotString() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Message must be string, "bool" given.'); + + new Vote(VoterInterface::ACCESS_GRANTED, ['foo', true]); + } +} 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 5636340e6aea2..b70b3e066ae9f 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoterTest.php @@ -12,12 +12,16 @@ namespace Symfony\Component\Security\Core\Tests\Authorization\Voter; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; class VoterTest extends TestCase { + use ExpectDeprecationTrait; + protected TokenInterface $token; protected function setUp(): void @@ -30,6 +34,44 @@ public static function getTests(): array $voter = new VoterTest_Voter(); $integerVoter = new IntegerVoterTest_Voter(); + return [ + [$voter, ['EDIT'], Vote::createGranted(), new \stdClass(), 'ACCESS_GRANTED if attribute and class are supported and attribute grants access'], + [$voter, ['CREATE'], Vote::createDenied(), new \stdClass(), 'ACCESS_DENIED if attribute and class are supported and attribute does not grant access'], + + [$voter, ['DELETE', 'EDIT'], Vote::createGranted(), new \stdClass(), 'ACCESS_GRANTED if one attribute is supported and grants access'], + [$voter, ['DELETE', 'CREATE'], Vote::createDenied(), new \stdClass(), 'ACCESS_DENIED if one attribute is supported and denies access'], + + [$voter, ['CREATE', 'EDIT'], Vote::createGranted(), new \stdClass(), 'ACCESS_GRANTED if one attribute grants access'], + + [$voter, ['DELETE'], Vote::createAbstain(), new \stdClass(), 'ACCESS_ABSTAIN if no attribute is supported'], + + [$voter, ['EDIT'], Vote::createAbstain(), new class() {}, 'ACCESS_ABSTAIN if class is not supported'], + + [$voter, ['EDIT'], Vote::createAbstain(), null, 'ACCESS_ABSTAIN if object is null'], + + [$voter, [], Vote::createAbstain(), new \stdClass(), 'ACCESS_ABSTAIN if no attributes were provided'], + + [$voter, [new StringableAttribute()], Vote::createGranted(), new \stdClass(), 'ACCESS_GRANTED if attribute and class are supported and attribute grants access'], + + [$voter, [new \stdClass()], Vote::createAbstain(), new \stdClass(), 'ACCESS_ABSTAIN if attributes were not strings'], + + [$integerVoter, [42], Vote::createGranted(), new \stdClass(), 'ACCESS_GRANTED if attribute is an integer'], + ]; + } + + /** + * @dataProvider getTests + */ + public function testGetVote(VoterInterface $voter, array $attributes, $expectedVote, $object, $message) + { + $this->assertEquals($expectedVote, $voter->getVote($this->token, $object, $attributes), $message); + } + + public function getTestsLegacy() + { + $voter = new VoterLegacyTest_Voter(); + $integerVoter = new IntegerVoterLegacyTest_Voter(); + return [ [$voter, ['EDIT'], VoterInterface::ACCESS_GRANTED, new \stdClass(), 'ACCESS_GRANTED if attribute and class are supported and attribute grants access'], [$voter, ['CREATE'], VoterInterface::ACCESS_DENIED, new \stdClass(), 'ACCESS_DENIED if attribute and class are supported and attribute does not grant access'], @@ -56,22 +98,52 @@ public static function getTests(): array } /** - * @dataProvider getTests + * @group legacy + * + * @dataProvider getTestsLegacy */ - public function testVote(VoterInterface $voter, array $attributes, $expectedVote, $object, $message) + public function testVoteLegacy(VoterInterface $voter, array $attributes, $expectedVote, $object, $message) { + $this->expectDeprecation('Since symfony/security-core 7.1: Method "%s::vote()" has been deprecated, use "%s::getVote()" instead.'); $this->assertEquals($expectedVote, $voter->vote($this->token, $object, $attributes), $message); } - public function testVoteWithTypeError() + public function testVoteMessage() + { + $voter = new IntegerVoterVoteTest_Voter(); + $vote = $voter->getVote($this->token, new \stdClass(), [43]); + $this->assertSame('foobar message', $vote->getMessage()); + } + + public function testVoteMessageMultipleAttributes() + { + $voter = new IntegerVoterVoteTest_Voter(); + $vote = $voter->getVote($this->token, new \stdClass(), [43, 44]); + $this->assertSame('foobar message, foobar message', $vote->getMessage()); + } + + public function testGetVoteWithTypeError() { $this->expectException(\TypeError::class); $this->expectExceptionMessage('Should error'); - (new TypeErrorVoterTest_Voter())->vote($this->token, new \stdClass(), ['EDIT']); + (new TypeErrorVoterTest_Voter())->getVote($this->token, new \stdClass(), ['EDIT']); } } class VoterTest_Voter extends Voter +{ + protected function voteOnAttribute(string $attribute, mixed $object, TokenInterface $token): Vote + { + return 'EDIT' === $attribute ? Vote::createGranted() : Vote::createDenied(); + } + + protected function supports(string $attribute, $object): bool + { + return $object instanceof \stdClass && \in_array($attribute, ['EDIT', 'CREATE']); + } +} + +class VoterLegacyTest_Voter extends Voter { protected function voteOnAttribute(string $attribute, $object, TokenInterface $token): bool { @@ -85,6 +157,19 @@ protected function supports(string $attribute, $object): bool } class IntegerVoterTest_Voter extends Voter +{ + protected function voteOnAttribute($attribute, $object, TokenInterface $token): Vote + { + return 42 === $attribute ? Vote::createGranted() : Vote::createDenied(); + } + + protected function supports($attribute, $object): bool + { + return $object instanceof \stdClass && \is_int($attribute); + } +} + +class IntegerVoterLegacyTest_Voter extends Voter { protected function voteOnAttribute($attribute, $object, TokenInterface $token): bool { @@ -97,6 +182,23 @@ protected function supports($attribute, $object): bool } } +class IntegerVoterVoteTest_Voter extends Voter +{ + protected function voteOnAttribute($attribute, $object, TokenInterface $token): Vote + { + if (42 === $attribute) { + return Vote::createGranted(); + } + + return Vote::createDenied('foobar message'); + } + + protected function supports($attribute, $object): bool + { + return $object instanceof \stdClass && \is_int($attribute); + } +} + class TypeErrorVoterTest_Voter extends Voter { protected function voteOnAttribute($attribute, $object, TokenInterface $token): bool 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..e536bd7132648 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Exception/AccessDeniedExceptionTest.php @@ -0,0 +1,72 @@ + + * + * 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\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 [ + AccessDecision::createDenied([ + Vote::createDenied('foo'), + Vote::createDenied('bar'), + Vote::createDenied('baz'), + ]), + 'Access Denied. Decision messages are "foo" and "bar" and "baz"', + ]; + + yield [ + AccessDecision::createDenied(), + 'Access Denied.', + ]; + + yield [ + AccessDecision::createDenied([ + Vote::createAbstain('foo'), + Vote::createDenied('bar'), + Vote::createAbstain('baz'), + ]), + 'Access Denied. Decision message is "bar"', + ]; + + yield [ + AccessDecision::createGranted([ + Vote::createDenied('foo'), + ]), + 'Access Denied. Decision message is "foo"', + ]; + + yield [ + AccessDecision::createGranted([ + Vote::createDenied(['foo', 'bar']), + Vote::createDenied(['baz', 'qux']), + ]), + 'Access Denied. Decision messages are "foo, bar" and "baz, qux"', + ]; + } +} diff --git a/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyVoter.php b/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyVoter.php index 1f923423a21ed..e32769e06b28c 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): Vote + { + } } diff --git a/src/Symfony/Component/Security/Core/composer.json b/src/Symfony/Component/Security/Core/composer.json index a923768be51da..7c0c4e165764d 100644 --- a/src/Symfony/Component/Security/Core/composer.json +++ b/src/Symfony/Component/Security/Core/composer.json @@ -26,6 +26,7 @@ "psr/cache": "^1.0|^2.0|^3.0", "symfony/cache": "^6.4|^7.0", "symfony/dependency-injection": "^6.4|^7.0", + "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/event-dispatcher": "^6.4|^7.0", "symfony/expression-language": "^6.4|^7.0", "symfony/http-foundation": "^6.4|^7.0", diff --git a/src/Symfony/Component/Security/Http/Firewall/AccessListener.php b/src/Symfony/Component/Security/Http/Firewall/AccessListener.php index 9f4337975c58b..e88d3032c9086 100644 --- a/src/Symfony/Component/Security/Http/Firewall/AccessListener.php +++ b/src/Symfony/Component/Security/Http/Firewall/AccessListener.php @@ -15,6 +15,7 @@ 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\Exception\AccessDeniedException; @@ -77,16 +78,23 @@ 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 = $this->accessDecisionManager->decide($token, $attributes, $request, true) ? AccessDecision::createGranted() : AccessDecision::createDenied(); + } + + 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 a8c7a652f6623..50d1584a99bbe 100644 --- a/src/Symfony/Component/Security/Http/Firewall/SwitchUserListener.php +++ b/src/Symfony/Component/Security/Http/Firewall/SwitchUserListener.php @@ -19,6 +19,7 @@ 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\Exception\AccessDeniedException; use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException; @@ -168,9 +169,16 @@ 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 = $this->accessDecisionManager->decide($token, [$this->role], $user) ? AccessDecision::createGranted() : AccessDecision::createDenied(); + } + + 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 e9bc31587ffba..331248d17ee4a 100644 --- a/src/Symfony/Component/Security/Http/Tests/Firewall/AccessListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Firewall/AccessListenerTest.php @@ -20,6 +20,7 @@ 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\Exception\AccessDeniedException; @@ -55,6 +56,58 @@ public function getCredentials(): mixed ->willReturn($token) ; + $accessDecisionManager = $this + ->getMockBuilder(AccessDecisionManagerInterface::class) + ->onlyMethods(['decide']) + ->addMethods(['getDecision']) + ->getMock(); + $accessDecisionManager + ->expects($this->once()) + ->method('getDecision') + ->with($this->equalTo($token), $this->equalTo(['foo' => 'bar']), $this->equalTo($request)) + ->willReturn(AccessDecision::createDenied()) + ; + + $listener = new AccessListener( + $tokenStorage, + $accessDecisionManager, + $accessMap + ); + + $this->expectException(AccessDeniedException::class); + + $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); + } + + /** + * @group legacy + */ + public function testHandleWhenTheAccessDecisionManagerDecidesToRefuseAccessLegacy() + { + $this->expectException(AccessDeniedException::class); + $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 = $this->createMock(AccessDecisionManagerInterface::class); $accessDecisionManager ->expects($this->once()) @@ -142,11 +195,15 @@ public function testHandleWhenTheSecurityTokenStorageHasNoToken() ->willReturn([['foo' => 'bar'], null]) ; - $accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class); + $accessDecisionManager = $this + ->getMockBuilder(AccessDecisionManagerInterface::class) + ->onlyMethods(['decide']) + ->addMethods(['getDecision']) + ->getMock(); $accessDecisionManager->expects($this->once()) - ->method('decide') + ->method('getDecision') ->with($this->isInstanceOf(NullToken::class)) - ->willReturn(false); + ->willReturn(AccessDecision::createDenied()); $listener = new AccessListener( $tokenStorage, @@ -172,11 +229,15 @@ public function testHandleWhenPublicAccessIsAllowed() ->willReturn([[AuthenticatedVoter::PUBLIC_ACCESS], null]) ; - $accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class); + $accessDecisionManager = $this + ->getMockBuilder(AccessDecisionManagerInterface::class) + ->onlyMethods(['decide']) + ->addMethods(['getDecision']) + ->getMock(); $accessDecisionManager->expects($this->once()) - ->method('decide') + ->method('getDecision') ->with($this->isInstanceOf(NullToken::class), [AuthenticatedVoter::PUBLIC_ACCESS]) - ->willReturn(true); + ->willReturn(AccessDecision::createGranted()); $listener = new AccessListener( $tokenStorage, @@ -202,11 +263,15 @@ public function testHandleWhenPublicAccessWhileAuthenticated() ->willReturn([[AuthenticatedVoter::PUBLIC_ACCESS], null]) ; - $accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class); + $accessDecisionManager = $this + ->getMockBuilder(AccessDecisionManagerInterface::class) + ->onlyMethods(['decide']) + ->addMethods(['getDecision']) + ->getMock(); $accessDecisionManager->expects($this->once()) - ->method('decide') + ->method('getDecision') ->with($this->equalTo($token), [AuthenticatedVoter::PUBLIC_ACCESS]) - ->willReturn(true); + ->willReturn(AccessDecision::createGranted()); $listener = new AccessListener( $tokenStorage, @@ -235,12 +300,16 @@ public function testHandleMWithultipleAttributesShouldBeHandledAsAnd() $tokenStorage = new TokenStorage(); $tokenStorage->setToken($authenticatedToken); - $accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class); + $accessDecisionManager = $this + ->getMockBuilder(AccessDecisionManagerInterface::class) + ->onlyMethods(['decide']) + ->addMethods(['getDecision']) + ->getMock(); $accessDecisionManager ->expects($this->once()) - ->method('decide') + ->method('getDecision') ->with($this->equalTo($authenticatedToken), $this->equalTo(['foo' => 'bar', 'bar' => 'baz']), $this->equalTo($request), true) - ->willReturn(true) + ->willReturn(AccessDecision::createGranted()) ; $listener = new AccessListener( diff --git a/src/Symfony/Component/Security/Http/Tests/Firewall/SwitchUserListenerTest.php b/src/Symfony/Component/Security/Http/Tests/Firewall/SwitchUserListenerTest.php index 46da56485d529..c7b5215fde4c9 100644 --- a/src/Symfony/Component/Security/Http/Tests/Firewall/SwitchUserListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Firewall/SwitchUserListenerTest.php @@ -20,6 +20,7 @@ use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken; 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\Exception\AccessDeniedException; use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException; @@ -45,7 +46,11 @@ protected function setUp(): void $this->tokenStorage = new TokenStorage(); $this->userProvider = new InMemoryUserProvider(['kuba' => []]); $this->userChecker = $this->createMock(UserCheckerInterface::class); - $this->accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class); + $this->accessDecisionManager = $this + ->getMockBuilder(AccessDecisionManagerInterface::class) + ->onlyMethods(['decide']) + ->addMethods(['getDecision']) + ->getMock(); $this->request = new Request(); $this->event = new RequestEvent($this->createMock(HttpKernelInterface::class), $this->request, HttpKernelInterface::MAIN_REQUEST); } @@ -145,8 +150,8 @@ public function testSwitchUserIsDisallowed() $this->request->query->set('_switch_user', 'kuba'); $this->accessDecisionManager->expects($this->once()) - ->method('decide')->with($token, ['ROLE_ALLOWED_TO_SWITCH']) - ->willReturn(false); + ->method('getDecision')->with($token, ['ROLE_ALLOWED_TO_SWITCH']) + ->willReturn(AccessDecision::createDenied()); $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager); @@ -163,7 +168,7 @@ public function testSwitchUserTurnsAuthenticationExceptionTo403() $this->request->query->set('_switch_user', 'not-existing'); $this->accessDecisionManager->expects($this->never()) - ->method('decide'); + ->method('getDecision'); $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager); @@ -180,8 +185,8 @@ public function testSwitchUser() $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); + ->method('getDecision')->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier())) + ->willReturn(AccessDecision::createGranted()); $this->userChecker->expects($this->once()) ->method('checkPostAuth')->with($this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier())); @@ -194,6 +199,32 @@ public function testSwitchUser() $this->assertInstanceOf(UsernamePasswordToken::class, $this->tokenStorage->getToken()); } + /** + * @group legacy + */ + public function testSwitchUserLegacy() + { + $token = new UsernamePasswordToken(new InMemoryUser('username', '', ['ROLE_FOO']), 'key', ['ROLE_FOO']); + + $this->tokenStorage->setToken($token); + $this->request->query->set('_switch_user', 'kuba'); + + $accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class); + $accessDecisionManager->expects($this->once()) + ->method('decide')->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $this->callback(function ($user) { return 'kuba' === $user->getUserIdentifier(); })) + ->willReturn(true); + + $this->userChecker->expects($this->once()) + ->method('checkPostAuth')->with($this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier())); + + $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $accessDecisionManager); + $listener($this->event); + + $this->assertSame([], $this->request->query->all()); + $this->assertSame('', $this->request->server->get('QUERY_STRING')); + $this->assertInstanceOf(UsernamePasswordToken::class, $this->tokenStorage->getToken()); + } + public function testSwitchUserAlreadySwitched() { $originalToken = new UsernamePasswordToken(new InMemoryUser('original', null, ['ROLE_FOO']), 'key', ['ROLE_FOO']); @@ -206,8 +237,8 @@ public function testSwitchUserAlreadySwitched() $targetsUser = $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier()); $this->accessDecisionManager->expects($this->once()) - ->method('decide')->with($originalToken, ['ROLE_ALLOWED_TO_SWITCH'], $targetsUser) - ->willReturn(true); + ->method('getDecision')->with($originalToken, ['ROLE_ALLOWED_TO_SWITCH'], $targetsUser) + ->willReturn(AccessDecision::createGranted()); $this->userChecker->expects($this->once()) ->method('checkPostAuth')->with($targetsUser); @@ -232,8 +263,8 @@ 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); + ->method('getDecision')->with($token, ['ROLE_ALLOWED_TO_SWITCH']) + ->willReturn(AccessDecision::createGranted()); $this->userChecker->expects($this->once()) ->method('checkPostAuth')->with($this->callback(fn ($argUser) => $user->isEqualTo($argUser))); @@ -259,8 +290,8 @@ 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); + ->method('getDecision')->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $targetsUser) + ->willReturn(AccessDecision::createGranted()); $this->userChecker->expects($this->once()) ->method('checkPostAuth')->with($targetsUser); @@ -284,8 +315,8 @@ public function testSwitchUserWithReplacedToken() $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); + ->method('getDecision')->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier())) + ->willReturn(AccessDecision::createGranted()); $dispatcher = $this->createMock(EventDispatcherInterface::class); $dispatcher @@ -329,8 +360,8 @@ public function testSwitchUserStateless() $targetsUser = $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier()); $this->accessDecisionManager->expects($this->once()) - ->method('decide')->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $targetsUser) - ->willReturn(true); + ->method('getDecision')->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $targetsUser) + ->willReturn(AccessDecision::createGranted()); $this->userChecker->expects($this->once()) ->method('checkPostAuth')->with($targetsUser); diff --git a/src/Symfony/Component/Security/Http/composer.json b/src/Symfony/Component/Security/Http/composer.json index 4bf955b99358c..df358900da9fa 100644 --- a/src/Symfony/Component/Security/Http/composer.json +++ b/src/Symfony/Component/Security/Http/composer.json @@ -43,6 +43,7 @@ "symfony/event-dispatcher": "<6.4", "symfony/http-client-contracts": "<3.0", "symfony/security-bundle": "<6.4", + "symfony/security-core": "<7.1", "symfony/security-csrf": "<6.4" }, "autoload": {