+ * @author Nicolas Grekas
+ */
+class AccessDecision
+{
+ /**
+ * @var class-string|string|null
+ */
+ public ?string $strategy = null;
+
+ public bool $isGranted;
+
+ /**
+ * @var Vote[]
+ */
+ public array $votes = [];
+
+ public function getMessage(): string
+ {
+ $message = $this->isGranted ? 'Access Granted.' : 'Access Denied.';
+ $access = $this->isGranted ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED;
+
+ if ($this->votes) {
+ foreach ($this->votes as $vote) {
+ if ($vote->result !== $access) {
+ continue;
+ }
+ foreach ($vote->reasons as $reason) {
+ $message .= ' '.$reason;
+ }
+ }
+ }
+
+ return $message;
+ }
+}
diff --git a/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php b/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php
index 3e42c4bf0af98..fd4353867f6aa 100644
--- a/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php
+++ b/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php
@@ -15,6 +15,8 @@
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\TraceableVoter;
+use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Security\Core\Exception\InvalidArgumentException;
@@ -35,6 +37,7 @@ final class AccessDecisionManager implements AccessDecisionManagerInterface
private array $votersCacheAttributes = [];
private array $votersCacheObject = [];
private AccessDecisionStrategyInterface $strategy;
+ private array $accessDecisionStack = [];
/**
* @param iterable $voters An array or an iterator of VoterInterface instances
@@ -49,35 +52,56 @@ public function __construct(
/**
* @param bool $allowMultipleAttributes Whether to allow passing multiple values to the $attributes array
*/
- public function decide(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false): bool
+ public function decide(TokenInterface $token, array $attributes, mixed $object = null, bool|AccessDecision|null $accessDecision = null, bool $allowMultipleAttributes = false): bool
{
+ if (\is_bool($accessDecision)) {
+ $allowMultipleAttributes = $accessDecision;
+ $accessDecision = null;
+ }
+
// Special case for AccessListener, do not remove the right side of the condition before 6.0
if (\count($attributes) > 1 && !$allowMultipleAttributes) {
throw new InvalidArgumentException(\sprintf('Passing more than one Security attribute to "%s()" is not supported.', __METHOD__));
}
- return $this->strategy->decide(
- $this->collectResults($token, $attributes, $object)
- );
+ $accessDecision ??= end($this->accessDecisionStack) ?: new AccessDecision();
+ $this->accessDecisionStack[] = $accessDecision;
+
+ $accessDecision->strategy = $this->strategy instanceof \Stringable ? $this->strategy : get_debug_type($this->strategy);
+
+ try {
+ return $accessDecision->isGranted = $this->strategy->decide(
+ $this->collectResults($token, $attributes, $object, $accessDecision)
+ );
+ } finally {
+ array_pop($this->accessDecisionStack);
+ }
}
/**
- * @return \Traversable
+ * @return \Traversable
*/
- private function collectResults(TokenInterface $token, array $attributes, mixed $object): \Traversable
+ private function collectResults(TokenInterface $token, array $attributes, mixed $object, AccessDecision $accessDecision): \Traversable
{
foreach ($this->getVoters($attributes, $object) as $voter) {
- $result = $voter->vote($token, $object, $attributes);
+ $vote = new Vote();
+ $result = $voter->vote($token, $object, $attributes, $vote);
+
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)));
}
+ $voter = $voter instanceof TraceableVoter ? $voter->getDecoratedVoter() : $voter;
+ $vote->voter = $voter instanceof \Stringable ? $voter : get_debug_type($voter);
+ $vote->result = $result;
+ $accessDecision->votes[] = $vote;
+
yield $result;
}
}
/**
- * @return iterable
+ * @return iterable
*/
private function getVoters(array $attributes, $object = null): iterable
{
diff --git a/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManagerInterface.php b/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManagerInterface.php
index f25c7e1bef9b3..cb4a3310d65bd 100644
--- a/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManagerInterface.php
+++ b/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManagerInterface.php
@@ -23,8 +23,9 @@ interface AccessDecisionManagerInterface
/**
* Decides whether the access is possible or not.
*
- * @param array $attributes An array of attributes associated with the method being invoked
- * @param mixed $object The object to secure
+ * @param array $attributes An array of attributes associated with the method being invoked
+ * @param mixed $object The object to secure
+ * @param AccessDecision|null $accessDecision Should be used to explain the decision
*/
- public function decide(TokenInterface $token, array $attributes, mixed $object = null): bool;
+ public function decide(TokenInterface $token, array $attributes, mixed $object = null/* , ?AccessDecision $accessDecision = null */): bool;
}
diff --git a/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php b/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php
index c748697c494f9..3960f2bea87cc 100644
--- a/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php
+++ b/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php
@@ -24,20 +24,28 @@
*/
class AuthorizationChecker implements AuthorizationCheckerInterface
{
+ private array $accessDecisionStack = [];
+
public function __construct(
private TokenStorageInterface $tokenStorage,
private AccessDecisionManagerInterface $accessDecisionManager,
) {
}
- final public function isGranted(mixed $attribute, mixed $subject = null): bool
+ final public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool
{
$token = $this->tokenStorage->getToken();
if (!$token || !$token->getUser()) {
$token = new NullToken();
}
+ $accessDecision ??= end($this->accessDecisionStack) ?: new AccessDecision();
+ $this->accessDecisionStack[] = $accessDecision;
- return $this->accessDecisionManager->decide($token, [$attribute], $subject);
+ try {
+ return $accessDecision->isGranted = $this->accessDecisionManager->decide($token, [$attribute], $subject, $accessDecision);
+ } finally {
+ array_pop($this->accessDecisionStack);
+ }
}
}
diff --git a/src/Symfony/Component/Security/Core/Authorization/AuthorizationCheckerInterface.php b/src/Symfony/Component/Security/Core/Authorization/AuthorizationCheckerInterface.php
index 6f5a6022178ba..7c673dfc8a306 100644
--- a/src/Symfony/Component/Security/Core/Authorization/AuthorizationCheckerInterface.php
+++ b/src/Symfony/Component/Security/Core/Authorization/AuthorizationCheckerInterface.php
@@ -21,7 +21,8 @@ interface AuthorizationCheckerInterface
/**
* Checks if the attribute is granted against the current authentication token and optionally supplied subject.
*
- * @param mixed $attribute A single attribute to vote on (can be of any type, string and instance of Expression are supported by the core)
+ * @param mixed $attribute A single attribute to vote on (can be of any type, string and instance of Expression are supported by the core)
+ * @param AccessDecision|null $accessDecision Should be used to explain the decision
*/
- public function isGranted(mixed $attribute, mixed $subject = null): bool;
+ public function isGranted(mixed $attribute, mixed $subject = null/* , ?AccessDecision $accessDecision = null */): bool;
}
diff --git a/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php b/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php
index 0b82eb3a6d96d..a03e2d0ca749b 100644
--- a/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php
+++ b/src/Symfony/Component/Security/Core/Authorization/TraceableAccessDecisionManager.php
@@ -12,7 +12,6 @@
namespace Symfony\Component\Security\Core\Authorization;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
-use Symfony\Component\Security\Core\Authorization\Strategy\AccessDecisionStrategyInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
/**
@@ -25,77 +24,68 @@
*/
class TraceableAccessDecisionManager implements AccessDecisionManagerInterface
{
- private ?AccessDecisionStrategyInterface $strategy = null;
- /** @var iterable */
- private iterable $voters = [];
+ private ?string $strategy = null;
+ /** @var array */
+ private array $voters = [];
private array $decisionLog = []; // All decision logs
private array $currentLog = []; // Logs being filled in
+ private array $accessDecisionStack = [];
public function __construct(
private AccessDecisionManagerInterface $manager,
) {
- // The strategy and voters are stored in a private properties of the decorated service
- if (property_exists($manager, 'strategy')) {
- $reflection = new \ReflectionProperty($manager::class, 'strategy');
- $this->strategy = $reflection->getValue($manager);
- }
- if (property_exists($manager, 'voters')) {
- $reflection = new \ReflectionProperty($manager::class, 'voters');
- $this->voters = $reflection->getValue($manager);
- }
}
- public function decide(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false): bool
+ public function decide(TokenInterface $token, array $attributes, mixed $object = null, bool|AccessDecision|null $accessDecision = null, bool $allowMultipleAttributes = false): bool
{
- $currentDecisionLog = [
+ if (\is_bool($accessDecision)) {
+ $allowMultipleAttributes = $accessDecision;
+ $accessDecision = null;
+ }
+
+ // Using a stack since decide can be called by voters
+ $this->currentLog[] = [
'attributes' => $attributes,
'object' => $object,
'voterDetails' => [],
];
- $this->currentLog[] = &$currentDecisionLog;
-
- $result = $this->manager->decide($token, $attributes, $object, $allowMultipleAttributes);
-
- $currentDecisionLog['result'] = $result;
-
- $this->decisionLog[] = array_pop($this->currentLog); // Using a stack since decide can be called by voters
-
- return $result;
+ $accessDecision ??= end($this->accessDecisionStack) ?: new AccessDecision();
+ $this->accessDecisionStack[] = $accessDecision;
+
+ try {
+ return $accessDecision->isGranted = $this->manager->decide($token, $attributes, $object, $accessDecision);
+ } finally {
+ $this->strategy = $accessDecision->strategy;
+ $currentLog = array_pop($this->currentLog);
+ if (isset($accessDecision->isGranted)) {
+ $currentLog['result'] = $accessDecision->isGranted;
+ }
+ $this->decisionLog[] = $currentLog;
+ }
}
- /**
- * Adds voter vote and class to the voter details.
- *
- * @param array $attributes attributes used for the vote
- * @param int $vote vote of the voter
- */
- public function addVoterVote(VoterInterface $voter, array $attributes, int $vote): void
+ public function addVoterVote(VoterInterface $voter, array $attributes, int $vote, array $reasons = []): void
{
$currentLogIndex = \count($this->currentLog) - 1;
$this->currentLog[$currentLogIndex]['voterDetails'][] = [
'voter' => $voter,
'attributes' => $attributes,
'vote' => $vote,
+ 'reasons' => $reasons,
];
+ $this->voters[$voter::class] = $voter;
}
public function getStrategy(): string
{
- if (null === $this->strategy) {
- return '-';
- }
- if ($this->strategy instanceof \Stringable) {
- return (string) $this->strategy;
- }
-
- return get_debug_type($this->strategy);
+ return $this->strategy ?? '-';
}
/**
- * @return iterable
+ * @return array
*/
- public function getVoters(): iterable
+ public function getVoters(): array
{
return $this->voters;
}
@@ -104,4 +94,11 @@ public function getDecisionLog(): array
{
return $this->decisionLog;
}
+
+ public function reset(): void
+ {
+ $this->strategy = null;
+ $this->voters = [];
+ $this->decisionLog = [];
+ }
}
diff --git a/src/Symfony/Component/Security/Core/Authorization/UserAuthorizationChecker.php b/src/Symfony/Component/Security/Core/Authorization/UserAuthorizationChecker.php
index f5ba7b8846e03..f515e5cbdeaea 100644
--- a/src/Symfony/Component/Security/Core/Authorization/UserAuthorizationChecker.php
+++ b/src/Symfony/Component/Security/Core/Authorization/UserAuthorizationChecker.php
@@ -24,8 +24,8 @@ public function __construct(
) {
}
- public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null): bool
+ public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool
{
- return $this->accessDecisionManager->decide(new UserAuthorizationCheckerToken($user), [$attribute], $subject);
+ return $this->accessDecisionManager->decide(new UserAuthorizationCheckerToken($user), [$attribute], $subject, $accessDecision);
}
}
diff --git a/src/Symfony/Component/Security/Core/Authorization/UserAuthorizationCheckerInterface.php b/src/Symfony/Component/Security/Core/Authorization/UserAuthorizationCheckerInterface.php
index 3335e6fd18830..15e5b4d43990d 100644
--- a/src/Symfony/Component/Security/Core/Authorization/UserAuthorizationCheckerInterface.php
+++ b/src/Symfony/Component/Security/Core/Authorization/UserAuthorizationCheckerInterface.php
@@ -23,7 +23,8 @@ interface UserAuthorizationCheckerInterface
/**
* Checks if the attribute is granted against the user and optionally supplied subject.
*
- * @param mixed $attribute A single attribute to vote on (can be of any type, string and instance of Expression are supported by the core)
+ * @param mixed $attribute A single attribute to vote on (can be of any type, string and instance of Expression are supported by the core)
+ * @param AccessDecision|null $accessDecision Should be used to explain the decision
*/
- public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null): bool;
+ public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool;
}
diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/AuthenticatedVoter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/AuthenticatedVoter.php
index a073f6168472a..1403aaaaf0b15 100644
--- a/src/Symfony/Component/Security/Core/Authorization/Voter/AuthenticatedVoter.php
+++ b/src/Symfony/Component/Security/Core/Authorization/Voter/AuthenticatedVoter.php
@@ -40,9 +40,17 @@ public function __construct(
) {
}
- public function vote(TokenInterface $token, mixed $subject, array $attributes): int
+ /**
+ * @param Vote|null $vote Should be used to explain the vote
+ */
+ public function vote(TokenInterface $token, mixed $subject, array $attributes/* , ?Vote $vote = null */): int
{
+ $vote = 3 < \func_num_args() ? func_get_arg(3) : new Vote();
+ $vote ??= new Vote();
+
if ($attributes === [self::PUBLIC_ACCESS]) {
+ $vote->reasons[] = 'Access is public.';
+
return VoterInterface::ACCESS_GRANTED;
}
@@ -62,30 +70,45 @@ public function vote(TokenInterface $token, mixed $subject, array $attributes):
$result = VoterInterface::ACCESS_DENIED;
- if (self::IS_AUTHENTICATED_FULLY === $attribute
- && $this->authenticationTrustResolver->isFullFledged($token)) {
+ if ((self::IS_AUTHENTICATED_FULLY === $attribute || self::IS_AUTHENTICATED_REMEMBERED === $attribute)
+ && $this->authenticationTrustResolver->isFullFledged($token)
+ ) {
+ $vote->reasons[] = 'The user is fully authenticated.';
+
return VoterInterface::ACCESS_GRANTED;
}
if (self::IS_AUTHENTICATED_REMEMBERED === $attribute
- && ($this->authenticationTrustResolver->isRememberMe($token)
- || $this->authenticationTrustResolver->isFullFledged($token))) {
+ && $this->authenticationTrustResolver->isRememberMe($token)
+ ) {
+ $vote->reasons[] = 'The user is remembered.';
+
return VoterInterface::ACCESS_GRANTED;
}
if (self::IS_AUTHENTICATED === $attribute && $this->authenticationTrustResolver->isAuthenticated($token)) {
+ $vote->reasons[] = 'The user is authenticated.';
+
return VoterInterface::ACCESS_GRANTED;
}
if (self::IS_REMEMBERED === $attribute && $this->authenticationTrustResolver->isRememberMe($token)) {
+ $vote->reasons[] = 'The user is remembered.';
+
return VoterInterface::ACCESS_GRANTED;
}
if (self::IS_IMPERSONATOR === $attribute && $token instanceof SwitchUserToken) {
+ $vote->reasons[] = 'The user is impersonating another user.';
+
return VoterInterface::ACCESS_GRANTED;
}
}
+ if (VoterInterface::ACCESS_DENIED === $result) {
+ $vote->reasons[] = 'The user is not appropriately authenticated.';
+ }
+
return $result;
}
diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php
index bab328307ac84..35d727a8eb15e 100644
--- a/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php
+++ b/src/Symfony/Component/Security/Core/Authorization/Voter/ExpressionVoter.php
@@ -28,7 +28,7 @@ class ExpressionVoter implements CacheableVoterInterface
{
public function __construct(
private ExpressionLanguage $expressionLanguage,
- private AuthenticationTrustResolverInterface $trustResolver,
+ private ?AuthenticationTrustResolverInterface $trustResolver,
private AuthorizationCheckerInterface $authChecker,
private ?RoleHierarchyInterface $roleHierarchy = null,
) {
@@ -44,10 +44,16 @@ public function supportsType(string $subjectType): bool
return true;
}
- public function vote(TokenInterface $token, mixed $subject, array $attributes): int
+ /**
+ * @param Vote|null $vote Should be used to explain the vote
+ */
+ public function vote(TokenInterface $token, mixed $subject, array $attributes/* , ?Vote $vote = null */): int
{
+ $vote = 3 < \func_num_args() ? func_get_arg(3) : new Vote();
+ $vote ??= new Vote();
$result = VoterInterface::ACCESS_ABSTAIN;
$variables = null;
+ $failingExpressions = [];
foreach ($attributes as $attribute) {
if (!$attribute instanceof Expression) {
continue;
@@ -56,9 +62,18 @@ public function vote(TokenInterface $token, mixed $subject, array $attributes):
$variables ??= $this->getVariables($token, $subject);
$result = VoterInterface::ACCESS_DENIED;
+
if ($this->expressionLanguage->evaluate($attribute, $variables)) {
+ $vote->reasons[] = \sprintf('Expression (%s) is true.', $attribute);
+
return VoterInterface::ACCESS_GRANTED;
}
+
+ $failingExpressions[] = $attribute;
+ }
+
+ if ($failingExpressions) {
+ $vote->reasons[] = \sprintf('Expression (%s) is false.', implode(') || (', $failingExpressions));
}
return $result;
@@ -78,10 +93,13 @@ private function getVariables(TokenInterface $token, mixed $subject): array
'object' => $subject,
'subject' => $subject,
'role_names' => $roleNames,
- 'trust_resolver' => $this->trustResolver,
'auth_checker' => $this->authChecker,
];
+ if ($this->trustResolver) {
+ $variables['trust_resolver'] = $this->trustResolver;
+ }
+
// this is mainly to propose a better experience when the expression is used
// in an access control rule, as the developer does not know that it's going
// to be handled by this voter
diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/RoleVoter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/RoleVoter.php
index 3c65fb634c047..46c08d15b48ed 100644
--- a/src/Symfony/Component/Security/Core/Authorization/Voter/RoleVoter.php
+++ b/src/Symfony/Component/Security/Core/Authorization/Voter/RoleVoter.php
@@ -25,10 +25,16 @@ public function __construct(
) {
}
- public function vote(TokenInterface $token, mixed $subject, array $attributes): int
+ /**
+ * @param Vote|null $vote Should be used to explain the vote
+ */
+ public function vote(TokenInterface $token, mixed $subject, array $attributes/* , ?Vote $vote = null */): int
{
+ $vote = 3 < \func_num_args() ? func_get_arg(3) : new Vote();
+ $vote ??= new Vote();
$result = VoterInterface::ACCESS_ABSTAIN;
$roles = $this->extractRoles($token);
+ $missingRoles = [];
foreach ($attributes as $attribute) {
if (!\is_string($attribute) || !str_starts_with($attribute, $this->prefix)) {
@@ -36,9 +42,18 @@ public function vote(TokenInterface $token, mixed $subject, array $attributes):
}
$result = VoterInterface::ACCESS_DENIED;
+
if (\in_array($attribute, $roles, true)) {
+ $vote->reasons[] = \sprintf('The user has %s.', $attribute);
+
return VoterInterface::ACCESS_GRANTED;
}
+
+ $missingRoles[] = $attribute;
+ }
+
+ if (VoterInterface::ACCESS_DENIED === $result) {
+ $vote->reasons[] = \sprintf('The user doesn\'t have%s %s.', 1 < \count($missingRoles) ? ' any of' : '', implode(', ', $missingRoles));
}
return $result;
diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/TraceableVoter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/TraceableVoter.php
index 1abc7c704fb59..47572797ee906 100644
--- a/src/Symfony/Component/Security/Core/Authorization/Voter/TraceableVoter.php
+++ b/src/Symfony/Component/Security/Core/Authorization/Voter/TraceableVoter.php
@@ -30,11 +30,11 @@ public function __construct(
) {
}
- public function vote(TokenInterface $token, mixed $subject, array $attributes): int
+ public function vote(TokenInterface $token, mixed $subject, array $attributes, ?Vote $vote = null): int
{
- $result = $this->voter->vote($token, $subject, $attributes);
+ $result = $this->voter->vote($token, $subject, $attributes, $vote ??= new Vote());
- $this->eventDispatcher->dispatch(new VoteEvent($this->voter, $subject, $attributes, $result), 'debug.security.authorization.vote');
+ $this->eventDispatcher->dispatch(new VoteEvent($this->voter, $subject, $attributes, $result, $vote->reasons), 'debug.security.authorization.vote');
return $result;
}
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..e933c57d996ee
--- /dev/null
+++ b/src/Symfony/Component/Security/Core/Authorization/Voter/Vote.php
@@ -0,0 +1,35 @@
+
+ *
+ * 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;
+
+class Vote
+{
+ /**
+ * @var class-string|string
+ */
+ public string $voter;
+
+ /**
+ * @var VoterInterface::ACCESS_*
+ */
+ public int $result;
+
+ /**
+ * @var list
+ */
+ public array $reasons = [];
+
+ public function addReason(string $reason): void
+ {
+ $this->reasons[] = $reason;
+ }
+}
diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php
index 1f76a42eaf1b8..3d7fd9e2d7a1f 100644
--- a/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php
+++ b/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php
@@ -24,10 +24,15 @@
*/
abstract class Voter implements VoterInterface, CacheableVoterInterface
{
- public function vote(TokenInterface $token, mixed $subject, array $attributes): int
+ /**
+ * @param Vote|null $vote Should be used to explain the vote
+ */
+ public function vote(TokenInterface $token, mixed $subject, array $attributes/* , ?Vote $vote = null */): int
{
+ $vote = 3 < \func_num_args() ? func_get_arg(3) : new Vote();
+ $vote ??= new Vote();
// abstain vote by default in case none of the attributes are supported
- $vote = self::ACCESS_ABSTAIN;
+ $vote->result = self::ACCESS_ABSTAIN;
foreach ($attributes as $attribute) {
try {
@@ -43,15 +48,15 @@ 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;
+ $vote->result = self::ACCESS_DENIED;
- if ($this->voteOnAttribute($attribute, $subject, $token)) {
+ if ($this->voteOnAttribute($attribute, $subject, $token, $vote)) {
// grant access as soon as at least one attribute returns a positive response
- return self::ACCESS_GRANTED;
+ return $vote->result = self::ACCESS_GRANTED;
}
}
- return $vote;
+ return $vote->result;
}
/**
@@ -90,6 +95,7 @@ abstract protected function supports(string $attribute, mixed $subject): bool;
*
* @param TAttribute $attribute
* @param TSubject $subject
+ * @param Vote|null $vote Should be used to explain the vote
*/
- abstract protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool;
+ abstract protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token/* , ?Vote $vote = null */): 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..0902a94be79f1 100644
--- a/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php
+++ b/src/Symfony/Component/Security/Core/Authorization/Voter/VoterInterface.php
@@ -30,10 +30,11 @@ interface VoterInterface
* This method must return one of the following constants:
* ACCESS_GRANTED, ACCESS_DENIED, or ACCESS_ABSTAIN.
*
- * @param mixed $subject The subject to secure
- * @param array $attributes An array of attributes associated with the method being invoked
+ * @param mixed $subject The subject to secure
+ * @param array $attributes An array of attributes associated with the method being invoked
+ * @param Vote|null $vote Should be used to explain the vote
*
* @return self::ACCESS_*
*/
- public function vote(TokenInterface $token, mixed $subject, array $attributes): int;
+ public function vote(TokenInterface $token, mixed $subject, array $attributes/* , ?Vote $vote = null */): int;
}
diff --git a/src/Symfony/Component/Security/Core/CHANGELOG.md b/src/Symfony/Component/Security/Core/CHANGELOG.md
index 290aa8b25e566..331b204cc1ae8 100644
--- a/src/Symfony/Component/Security/Core/CHANGELOG.md
+++ b/src/Symfony/Component/Security/Core/CHANGELOG.md
@@ -9,6 +9,7 @@ CHANGELOG
* Add `OfflineTokenInterface` to mark tokens that do not represent the currently logged-in user
* Deprecate `UserInterface::eraseCredentials()` and `TokenInterface::eraseCredentials()`,
erase credentials e.g. using `__serialize()` instead
+ * Add ability for voters to explain their vote
7.2
---
diff --git a/src/Symfony/Component/Security/Core/Event/VoteEvent.php b/src/Symfony/Component/Security/Core/Event/VoteEvent.php
index edc66b3667ec2..5842c541e657f 100644
--- a/src/Symfony/Component/Security/Core/Event/VoteEvent.php
+++ b/src/Symfony/Component/Security/Core/Event/VoteEvent.php
@@ -28,6 +28,7 @@ public function __construct(
private mixed $subject,
private array $attributes,
private int $vote,
+ private array $reasons = [],
) {
}
@@ -50,4 +51,9 @@ public function getVote(): int
{
return $this->vote;
}
+
+ public function getReasons(): array
+ {
+ return $this->reasons;
+ }
}
diff --git a/src/Symfony/Component/Security/Core/Exception/AccessDeniedException.php b/src/Symfony/Component/Security/Core/Exception/AccessDeniedException.php
index 93c3869470d05..a3e5747eb4da3 100644
--- a/src/Symfony/Component/Security/Core/Exception/AccessDeniedException.php
+++ b/src/Symfony/Component/Security/Core/Exception/AccessDeniedException.php
@@ -12,6 +12,7 @@
namespace Symfony\Component\Security\Core\Exception;
use Symfony\Component\HttpKernel\Attribute\WithHttpStatus;
+use Symfony\Component\Security\Core\Authorization\AccessDecision;
/**
* AccessDeniedException is thrown when the account has not the required role.
@@ -23,6 +24,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 +50,14 @@ public function setSubject(mixed $subject): void
{
$this->subject = $subject;
}
+
+ public function setAccessDecision(AccessDecision $accessDecision): void
+ {
+ $this->accessDecision = $accessDecision;
+ }
+
+ public function getAccessDecision(): ?AccessDecision
+ {
+ return $this->accessDecision;
+ }
}
diff --git a/src/Symfony/Component/Security/Core/Test/AccessDecisionStrategyTestCase.php b/src/Symfony/Component/Security/Core/Test/AccessDecisionStrategyTestCase.php
index 792e777915400..563a6138b0b0d 100644
--- a/src/Symfony/Component/Security/Core/Test/AccessDecisionStrategyTestCase.php
+++ b/src/Symfony/Component/Security/Core/Test/AccessDecisionStrategyTestCase.php
@@ -16,6 +16,7 @@
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
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;
/**
@@ -71,7 +72,7 @@ public function __construct(
) {
}
- public function vote(TokenInterface $token, $subject, array $attributes): int
+ public function vote(TokenInterface $token, $subject, array $attributes, ?Vote $vote = null): int
{
return $this->vote;
}
diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerTest.php
index 8797d74d79f0c..f5313bb541c22 100644
--- a/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerTest.php
+++ b/src/Symfony/Component/Security/Core/Tests/Authorization/TraceableAccessDecisionManagerTest.php
@@ -61,8 +61,8 @@ public static function provideObjectsAndLogs(): \Generator
'object' => null,
'result' => true,
'voterDetails' => [
- ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1'], 'vote' => VoterInterface::ACCESS_GRANTED],
- ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1'], 'vote' => VoterInterface::ACCESS_GRANTED],
+ ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1'], 'vote' => VoterInterface::ACCESS_GRANTED, 'reasons' => []],
+ ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1'], 'vote' => VoterInterface::ACCESS_GRANTED, 'reasons' => []],
],
]],
['ATTRIBUTE_1'],
@@ -79,8 +79,8 @@ public static function provideObjectsAndLogs(): \Generator
'object' => true,
'result' => false,
'voterDetails' => [
- ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_ABSTAIN],
- ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_GRANTED],
+ ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_ABSTAIN, 'reasons' => []],
+ ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_1', 'ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_GRANTED, 'reasons' => []],
],
]],
['ATTRIBUTE_1', 'ATTRIBUTE_2'],
@@ -97,8 +97,8 @@ public static function provideObjectsAndLogs(): \Generator
'object' => 'jolie string',
'result' => false,
'voterDetails' => [
- ['voter' => $voter1, 'attributes' => [null], 'vote' => VoterInterface::ACCESS_ABSTAIN],
- ['voter' => $voter2, 'attributes' => [null], 'vote' => VoterInterface::ACCESS_DENIED],
+ ['voter' => $voter1, 'attributes' => [null], 'vote' => VoterInterface::ACCESS_ABSTAIN, 'reasons' => []],
+ ['voter' => $voter2, 'attributes' => [null], 'vote' => VoterInterface::ACCESS_DENIED, 'reasons' => []],
],
]],
[null],
@@ -139,8 +139,8 @@ public static function provideObjectsAndLogs(): \Generator
'object' => $x = [],
'result' => false,
'voterDetails' => [
- ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_ABSTAIN],
- ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_ABSTAIN],
+ ['voter' => $voter1, 'attributes' => ['ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_ABSTAIN, 'reasons' => []],
+ ['voter' => $voter2, 'attributes' => ['ATTRIBUTE_2'], 'vote' => VoterInterface::ACCESS_ABSTAIN, 'reasons' => []],
],
]],
['ATTRIBUTE_2'],
@@ -157,8 +157,8 @@ public static function provideObjectsAndLogs(): \Generator
'object' => new \stdClass(),
'result' => false,
'voterDetails' => [
- ['voter' => $voter1, 'attributes' => [12.13], 'vote' => VoterInterface::ACCESS_DENIED],
- ['voter' => $voter2, 'attributes' => [12.13], 'vote' => VoterInterface::ACCESS_DENIED],
+ ['voter' => $voter1, 'attributes' => [12.13], 'vote' => VoterInterface::ACCESS_DENIED, 'reasons' => []],
+ ['voter' => $voter2, 'attributes' => [12.13], 'vote' => VoterInterface::ACCESS_DENIED, 'reasons' => []],
],
]],
[12.13],
@@ -242,7 +242,7 @@ public function testAccessDecisionManagerCalledByVoter()
'attributes' => ['attr1'],
'object' => null,
'voterDetails' => [
- ['voter' => $voter1, 'attributes' => ['attr1'], 'vote' => VoterInterface::ACCESS_GRANTED],
+ ['voter' => $voter1, 'attributes' => ['attr1'], 'vote' => VoterInterface::ACCESS_GRANTED, 'reasons' => []],
],
'result' => true,
],
@@ -250,8 +250,8 @@ public function testAccessDecisionManagerCalledByVoter()
'attributes' => ['attr2'],
'object' => null,
'voterDetails' => [
- ['voter' => $voter1, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_ABSTAIN],
- ['voter' => $voter2, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_GRANTED],
+ ['voter' => $voter1, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_ABSTAIN, 'reasons' => []],
+ ['voter' => $voter2, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_GRANTED, 'reasons' => []],
],
'result' => true,
],
@@ -259,9 +259,9 @@ public function testAccessDecisionManagerCalledByVoter()
'attributes' => ['attr2'],
'object' => $obj,
'voterDetails' => [
- ['voter' => $voter1, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_ABSTAIN],
- ['voter' => $voter2, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_DENIED],
- ['voter' => $voter3, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_GRANTED],
+ ['voter' => $voter1, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_ABSTAIN, 'reasons' => []],
+ ['voter' => $voter2, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_DENIED, 'reasons' => []],
+ ['voter' => $voter3, 'attributes' => ['attr2'], 'vote' => VoterInterface::ACCESS_GRANTED, 'reasons' => []],
],
'result' => 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 602c61ab08a34..a8f87e09da7e6 100644
--- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoterTest.php
+++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoterTest.php
@@ -13,6 +13,7 @@
use PHPUnit\Framework\TestCase;
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;
@@ -73,7 +74,7 @@ public function testVoteWithTypeError()
class VoterTest_Voter extends Voter
{
- protected function voteOnAttribute(string $attribute, $object, TokenInterface $token): bool
+ protected function voteOnAttribute(string $attribute, $object, TokenInterface $token, ?Vote $vote = null): bool
{
return 'EDIT' === $attribute;
}
@@ -86,7 +87,7 @@ protected function supports(string $attribute, $object): bool
class IntegerVoterTest_Voter extends Voter
{
- protected function voteOnAttribute($attribute, $object, TokenInterface $token): bool
+ protected function voteOnAttribute($attribute, $object, TokenInterface $token, ?Vote $vote = null): bool
{
return 42 === $attribute;
}
@@ -99,7 +100,7 @@ protected function supports($attribute, $object): bool
class TypeErrorVoterTest_Voter extends Voter
{
- protected function voteOnAttribute($attribute, $object, TokenInterface $token): bool
+ protected function voteOnAttribute($attribute, $object, TokenInterface $token, ?Vote $vote = null): bool
{
return false;
}
diff --git a/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyVoter.php b/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyVoter.php
index 1f923423a21ed..16ae8e8a6a0b4 100644
--- a/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyVoter.php
+++ b/src/Symfony/Component/Security/Core/Tests/Fixtures/DummyVoter.php
@@ -12,11 +12,12 @@
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
{
- public function vote(TokenInterface $token, $subject, array $attributes): int
+ public function vote(TokenInterface $token, $subject, array $attributes, ?Vote $vote = null): int
{
}
}
diff --git a/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php b/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php
index c511cf04e2398..5ac76c2ba9b02 100644
--- a/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php
+++ b/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php
@@ -18,6 +18,7 @@
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\KernelEvents;
+use Symfony\Component\Security\Core\Authorization\AccessDecision;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Exception\RuntimeException;
@@ -58,19 +59,21 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo
$subject = $this->getIsGrantedSubject($subjectRef, $request, $arguments);
}
}
+ $accessDecision = new AccessDecision();
- if (!$this->authChecker->isGranted($attribute->attribute, $subject)) {
- $message = $attribute->message ?: \sprintf('Access Denied by #[IsGranted(%s)] on controller', $this->getIsGrantedString($attribute));
+ if (!$accessDecision->isGranted = $this->authChecker->isGranted($attribute->attribute, $subject, $accessDecision)) {
+ $message = $attribute->message ?: $accessDecision->getMessage();
if ($statusCode = $attribute->statusCode) {
throw new HttpException($statusCode, $message, code: $attribute->exceptionCode ?? 0);
}
- $accessDeniedException = new AccessDeniedException($message, code: $attribute->exceptionCode ?? 403);
- $accessDeniedException->setAttributes($attribute->attribute);
- $accessDeniedException->setSubject($subject);
+ $e = new AccessDeniedException($message, code: $attribute->exceptionCode ?? 403);
+ $e->setAttributes($attribute->attribute);
+ $e->setSubject($subject);
+ $e->setAccessDecision($accessDecision);
- throw $accessDeniedException;
+ throw $e;
}
}
}
@@ -97,23 +100,4 @@ private function getIsGrantedSubject(string|Expression $subjectRef, Request $req
return $arguments[$subjectRef];
}
-
- private function getIsGrantedString(IsGranted $isGranted): string
- {
- $processValue = fn ($value) => \sprintf($value instanceof Expression ? 'new Expression("%s")' : '"%s"', $value);
-
- $argsString = $processValue($isGranted->attribute);
-
- if (null !== $subject = $isGranted->subject) {
- $subject = !\is_array($subject) ? $processValue($subject) : array_map(function ($key, $value) use ($processValue) {
- $value = $processValue($value);
-
- return \is_string($key) ? \sprintf('"%s" => %s', $key, $value) : $value;
- }, array_keys($subject), $subject);
-
- $argsString .= ', '.(!\is_array($subject) ? $subject : '['.implode(', ', $subject).']');
- }
-
- return $argsString;
- }
}
diff --git a/src/Symfony/Component/Security/Http/Firewall/AccessListener.php b/src/Symfony/Component/Security/Http/Firewall/AccessListener.php
index f04de5a9fcd50..8bfcb674e12de 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;
@@ -72,19 +73,16 @@ public function authenticate(RequestEvent $event): void
}
$token = $this->tokenStorage->getToken() ?? new NullToken();
+ $accessDecision = new AccessDecision();
- if (!$this->accessDecisionManager->decide($token, $attributes, $request, true)) {
- throw $this->createAccessDeniedException($request, $attributes);
- }
- }
+ if (!$accessDecision->isGranted = $this->accessDecisionManager->decide($token, $attributes, $request, $accessDecision, true)) {
+ $e = new AccessDeniedException($accessDecision->getMessage());
+ $e->setAttributes($attributes);
+ $e->setSubject($request);
+ $e->setAccessDecision($accessDecision);
- private function createAccessDeniedException(Request $request, array $attributes): AccessDeniedException
- {
- $exception = new AccessDeniedException();
- $exception->setAttributes($attributes);
- $exception->setSubject($request);
-
- return $exception;
+ throw $e;
+ }
}
public static function getPriority(): int
diff --git a/src/Symfony/Component/Security/Http/Firewall/SwitchUserListener.php b/src/Symfony/Component/Security/Http/Firewall/SwitchUserListener.php
index ffdad52eef207..8c03e85681ae0 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;
@@ -153,12 +154,14 @@ private function attemptSwitchUser(Request $request, string $username): ?TokenIn
throw $e;
}
+ $accessDecision = new AccessDecision();
- if (false === $this->accessDecisionManager->decide($token, [$this->role], $user)) {
- $exception = new AccessDeniedException();
- $exception->setAttributes($this->role);
+ if (!$accessDecision->isGranted = $this->accessDecisionManager->decide($token, [$this->role], $user, $accessDecision)) {
+ $e = new AccessDeniedException($accessDecision->getMessage());
+ $e->setAttributes($this->role);
+ $e->setAccessDecision($accessDecision);
- throw $exception;
+ throw $e;
}
$this->logger?->info('Attempting to switch to user.', ['username' => $username]);
diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeListenerTest.php
index 2d03b7ac357ea..81ca1cb32fad3 100644
--- a/src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeListenerTest.php
+++ b/src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeListenerTest.php
@@ -13,12 +13,20 @@
use PHPUnit\Framework\TestCase;
use Symfony\Component\ExpressionLanguage\Expression;
-use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\HttpKernelInterface;
+use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\AccessDecisionManager;
+use Symfony\Component\Security\Core\Authorization\AuthorizationChecker;
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\RoleVoter;
+use Symfony\Component\Security\Core\Authorization\Voter\Vote;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Http\EventListener\IsGrantedAttributeListener;
use Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeController;
@@ -213,10 +221,23 @@ public function testExceptionWhenMissingSubjectAttribute()
*/
public function testAccessDeniedMessages(string|Expression $attribute, string|array|null $subject, string $method, int $numOfArguments, string $expectedMessage)
{
- $authChecker = $this->createMock(AuthorizationCheckerInterface::class);
- $authChecker->expects($this->any())
- ->method('isGranted')
- ->willReturn(false);
+ $authChecker = new AuthorizationChecker(new TokenStorage(), new AccessDecisionManager((function () use (&$authChecker) {
+ yield new ExpressionVoter(new ExpressionLanguage(), null, $authChecker);
+ yield new RoleVoter();
+ yield new class() extends Voter {
+ protected function supports(string $attribute, mixed $subject): bool
+ {
+ return 'POST_VIEW' === $attribute;
+ }
+
+ protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
+ {
+ $vote->reasons[] = 'Because I can 😈.';
+
+ return false;
+ }
+ };
+ })()));
$expressionLanguage = $this->createMock(ExpressionLanguage::class);
$expressionLanguage->expects($this->any())
@@ -252,12 +273,12 @@ public function testAccessDeniedMessages(string|Expression $attribute, string|ar
public static function getAccessDeniedMessageTests()
{
- yield ['ROLE_ADMIN', null, 'admin', 0, 'Access Denied by #[IsGranted("ROLE_ADMIN")] on controller'];
- yield ['ROLE_ADMIN', 'bar', 'withSubject', 2, 'Access Denied by #[IsGranted("ROLE_ADMIN", "arg2Name")] on controller'];
- yield ['ROLE_ADMIN', ['arg1Name' => 'bar', 'arg2Name' => 'bar'], 'withSubjectArray', 2, 'Access Denied by #[IsGranted("ROLE_ADMIN", ["arg1Name", "arg2Name"])] on controller'];
- yield [new Expression('"ROLE_ADMIN" in role_names or is_granted("POST_VIEW", subject)'), 'bar', 'withExpressionInAttribute', 1, 'Access Denied by #[IsGranted(new Expression(""ROLE_ADMIN" in role_names or is_granted("POST_VIEW", subject)"), "post")] on controller'];
- yield [new Expression('user === subject'), 'bar', 'withExpressionInSubject', 1, 'Access Denied by #[IsGranted(new Expression("user === subject"), new Expression("args["post"].getAuthor()"))] on controller'];
- yield [new Expression('user === subject["author"]'), ['author' => 'bar', 'alias' => 'bar'], 'withNestedExpressionInSubject', 2, 'Access Denied by #[IsGranted(new Expression("user === subject["author"]"), ["author" => new Expression("args["post"].getAuthor()"), "alias" => "arg2Name"])] on controller'];
+ yield ['ROLE_ADMIN', null, 'admin', 0, 'Access Denied. The user doesn\'t have ROLE_ADMIN.'];
+ yield ['ROLE_ADMIN', 'bar', 'withSubject', 2, 'Access Denied. The user doesn\'t have ROLE_ADMIN.'];
+ yield ['ROLE_ADMIN', ['arg1Name' => 'bar', 'arg2Name' => 'bar'], 'withSubjectArray', 2, 'Access Denied. The user doesn\'t have ROLE_ADMIN.'];
+ yield [new Expression('"ROLE_ADMIN" in role_names or is_granted("POST_VIEW", subject)'), 'bar', 'withExpressionInAttribute', 1, 'Access Denied. Because I can 😈. Expression ("ROLE_ADMIN" in role_names or is_granted("POST_VIEW", subject)) is false.'];
+ yield [new Expression('user === subject'), 'bar', 'withExpressionInSubject', 1, 'Access Denied. Expression (user === subject) is false.'];
+ yield [new Expression('user === subject["author"]'), ['author' => 'bar', 'alias' => 'bar'], 'withNestedExpressionInSubject', 2, 'Access Denied. Expression (user === subject["author"]) is false.'];
}
public function testNotFoundHttpException()
diff --git a/src/Symfony/Component/Security/Http/Tests/Firewall/AccessListenerTest.php b/src/Symfony/Component/Security/Http/Tests/Firewall/AccessListenerTest.php
index 5decf414251f9..83df93d36169f 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;
@@ -235,7 +236,7 @@ public function testHandleMWithultipleAttributesShouldBeHandledAsAnd()
$accessDecisionManager
->expects($this->once())
->method('decide')
- ->with($this->equalTo($authenticatedToken), $this->equalTo(['foo' => 'bar', 'bar' => 'baz']), $this->equalTo($request), true)
+ ->with($this->equalTo($authenticatedToken), $this->equalTo(['foo' => 'bar', 'bar' => 'baz']), $this->equalTo($request), new AccessDecision(), true)
->willReturn(true)
;