Skip to content

Navigation Menu

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit fcc69a6

Browse filesBrowse files
[Security] Add ability for voters to explain their vote
1 parent d824d53 commit fcc69a6
Copy full SHA for fcc69a6

27 files changed

+330
-120
lines changed

‎src/Symfony/Bridge/Twig/Extension/SecurityExtension.php

Copy file name to clipboardExpand all lines: src/Symfony/Bridge/Twig/Extension/SecurityExtension.php
+26-5Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\Bridge\Twig\Extension;
1313

1414
use Symfony\Component\Security\Acl\Voter\FieldVote;
15+
use Symfony\Component\Security\Core\Authorization\AccessDecision;
1516
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
1617
use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface;
1718
use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException;
@@ -34,7 +35,7 @@ public function __construct(
3435
) {
3536
}
3637

37-
public function isGranted(mixed $role, mixed $object = null, ?string $field = null): bool
38+
public function isGranted(mixed $role, mixed $object = null, ?string $field = null, ?AccessDecision $accessDecision = null): bool
3839
{
3940
if (null === $this->securityChecker) {
4041
return false;
@@ -47,15 +48,23 @@ public function isGranted(mixed $role, mixed $object = null, ?string $field = nu
4748

4849
$object = new FieldVote($object, $field);
4950
}
51+
if (!class_exists(AccessDecision::class)) {
52+
try {
53+
return $this->securityChecker->isGranted($role, $object);
54+
} catch (AuthenticationCredentialsNotFoundException) {
55+
return false;
56+
}
57+
}
58+
$accessDecision ??= new AccessDecision();
5059

5160
try {
52-
return $this->securityChecker->isGranted($role, $object);
61+
return $accessDecision->isGranted = $this->securityChecker->isGranted($role, $object, $accessDecision);
5362
} catch (AuthenticationCredentialsNotFoundException) {
54-
return false;
63+
return $accessDecision->isGranted = false;
5564
}
5665
}
5766

58-
public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?string $field = null): bool
67+
public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?string $field = null, ?AccessDecision $accessDecision = null): bool
5968
{
6069
if (!$this->userSecurityChecker) {
6170
throw new \LogicException(\sprintf('An instance of "%s" must be provided to use "%s()".', UserAuthorizationCheckerInterface::class, __METHOD__));
@@ -68,8 +77,20 @@ public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $s
6877

6978
$subject = new FieldVote($subject, $field);
7079
}
80+
if (!class_exists(AccessDecision::class)) {
81+
try {
82+
return $this->userSecurityChecker->isGrantedForUser($user, $attribute, $subject);
83+
} catch (AuthenticationCredentialsNotFoundException) {
84+
return false;
85+
}
86+
}
87+
$accessDecision ??= new AccessDecision();
7188

72-
return $this->userSecurityChecker->isGrantedForUser($user, $attribute, $subject);
89+
try {
90+
return $accessDecision->isGranted = $this->userSecurityChecker->isGrantedForUser($user, $attribute, $subject, $accessDecision);
91+
} catch (AuthenticationCredentialsNotFoundException) {
92+
return $accessDecision->isGranted = false;
93+
}
7394
}
7495

7596
public function getImpersonateExitUrl(?string $exitTo = null): string

‎src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php
+33-5Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
3636
use Symfony\Component\Routing\RouterInterface;
3737
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
38+
use Symfony\Component\Security\Core\Authorization\AccessDecision;
3839
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
3940
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
4041
use Symfony\Component\Security\Core\User\UserInterface;
@@ -202,6 +203,21 @@ protected function isGranted(mixed $attribute, mixed $subject = null): bool
202203
return $this->container->get('security.authorization_checker')->isGranted($attribute, $subject);
203204
}
204205

206+
/**
207+
* Checks if the attribute is granted against the current authentication token and optionally supplied subject.
208+
*/
209+
protected function getAccessDecision(mixed $attribute, mixed $subject = null): AccessDecision
210+
{
211+
if (!$this->container->has('security.authorization_checker')) {
212+
throw new \LogicException('The SecurityBundle is not registered in your application. Try running "composer require symfony/security-bundle".');
213+
}
214+
215+
$accessDecision = new AccessDecision();
216+
$accessDecision->isGranted = $this->container->get('security.authorization_checker')->isGranted($attribute, $subject, $accessDecision);
217+
218+
return $accessDecision;
219+
}
220+
205221
/**
206222
* Throws an exception unless the attribute is granted against the current authentication token and optionally
207223
* supplied subject.
@@ -210,12 +226,24 @@ protected function isGranted(mixed $attribute, mixed $subject = null): bool
210226
*/
211227
protected function denyAccessUnlessGranted(mixed $attribute, mixed $subject = null, string $message = 'Access Denied.'): void
212228
{
213-
if (!$this->isGranted($attribute, $subject)) {
214-
$exception = $this->createAccessDeniedException($message);
215-
$exception->setAttributes([$attribute]);
216-
$exception->setSubject($subject);
229+
if (class_exists(AccessDecision::class)) {
230+
$accessDecision = $this->getAccessDecision($attribute, $subject);
231+
$isGranted = $accessDecision->isGranted;
232+
} else {
233+
$accessDecision = null;
234+
$isGranted = $this->isGranted($attribute, $subject);
235+
}
236+
237+
if (!$isGranted) {
238+
$e = $this->createAccessDeniedException(3 > \func_num_args() && $accessDecision ? $accessDecision->getMessage() : $message);
239+
$e->setAttributes([$attribute]);
240+
$e->setSubject($subject);
241+
242+
if ($accessDecision) {
243+
$e->setAccessDecision($accessDecision);
244+
}
217245

218-
throw $exception;
246+
throw $e;
219247
}
220248
}
221249

‎src/Symfony/Bundle/SecurityBundle/DataCollector/SecurityDataCollector.php

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/SecurityBundle/DataCollector/SecurityDataCollector.php
+2Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ public function collect(Request $request, Response $response, ?\Throwable $excep
138138

139139
// collect voter details
140140
$decisionLog = $this->accessDecisionManager->getDecisionLog();
141+
141142
foreach ($decisionLog as $key => $log) {
142143
$decisionLog[$key]['voter_details'] = [];
143144
foreach ($log['voterDetails'] as $voterDetail) {
@@ -147,6 +148,7 @@ public function collect(Request $request, Response $response, ?\Throwable $excep
147148
'class' => $classData,
148149
'attributes' => $voterDetail['attributes'], // Only displayed for unanimous strategy
149150
'vote' => $voterDetail['vote'],
151+
'reasons' => $voterDetail['reasons'] ?? [],
150152
];
151153
}
152154
unset($decisionLog[$key]['voterDetails']);

‎src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php
+1-1Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public function __construct(
3131

3232
public function onVoterVote(VoteEvent $event): void
3333
{
34-
$this->traceableAccessDecisionManager->addVoterVote($event->getVoter(), $event->getAttributes(), $event->getVote());
34+
$this->traceableAccessDecisionManager->addVoterVote($event->getVoter(), $event->getAttributes(), $event->getVote(), $event->getReasons());
3535
}
3636

3737
public static function getSubscribedEvents(): array

‎src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig
+6-3Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -571,14 +571,17 @@
571571
{% endif %}
572572
<td class="font-normal text-small">
573573
{% if voter_detail['vote'] == constant('Symfony\\Component\\Security\\Core\\Authorization\\Voter\\VoterInterface::ACCESS_GRANTED') %}
574-
ACCESS GRANTED
574+
GRANTED
575575
{% elseif voter_detail['vote'] == constant('Symfony\\Component\\Security\\Core\\Authorization\\Voter\\VoterInterface::ACCESS_ABSTAIN') %}
576-
ACCESS ABSTAIN
576+
ABSTAIN
577577
{% elseif voter_detail['vote'] == constant('Symfony\\Component\\Security\\Core\\Authorization\\Voter\\VoterInterface::ACCESS_DENIED') %}
578-
ACCESS DENIED
578+
DENIED
579579
{% else %}
580580
unknown ({{ voter_detail['vote'] }})
581581
{% endif %}
582+
{% if voter_detail['reasons'] is not empty %}
583+
<br>{{ voter_detail['reasons'] | join('<br>') }}
584+
{% endif %}
582585
</td>
583586
</tr>
584587
{% endfor %}

‎src/Symfony/Bundle/SecurityBundle/Security.php

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/SecurityBundle/Security.php
+7-6Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Symfony\Component\HttpFoundation\Response;
1818
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
1919
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
20+
use Symfony\Component\Security\Core\Authorization\AccessDecision;
2021
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
2122
use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface;
2223
use Symfony\Component\Security\Core\Exception\LogicException;
@@ -58,10 +59,10 @@ public function getUser(): ?UserInterface
5859
/**
5960
* Checks if the attributes are granted against the current authentication token and optionally supplied subject.
6061
*/
61-
public function isGranted(mixed $attributes, mixed $subject = null): bool
62+
public function isGranted(mixed $attributes, mixed $subject = null, AccessDecision $accessDecision = new AccessDecision()): bool
6263
{
63-
return $this->container->get('security.authorization_checker')
64-
->isGranted($attributes, $subject);
64+
return $accessDecision->isGranted = $this->container->get('security.authorization_checker')
65+
->isGranted($attributes, $subject, $accessDecision);
6566
}
6667

6768
public function getToken(): ?TokenInterface
@@ -154,10 +155,10 @@ public function logout(bool $validateCsrfToken = true): ?Response
154155
*
155156
* This should be used over isGranted() when checking permissions against a user that is not currently logged in or while in a CLI context.
156157
*/
157-
public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null): bool
158+
public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, AccessDecision $accessDecision = new AccessDecision()): bool
158159
{
159-
return $this->container->get('security.user_authorization_checker')
160-
->isGrantedForUser($user, $attribute, $subject);
160+
return $accessDecision->isGranted = $this->container->get('security.user_authorization_checker')
161+
->isGrantedForUser($user, $attribute, $subject, $accessDecision);
161162
}
162163

163164
private function getAuthenticator(?string $authenticatorName, string $firewallName): AuthenticatorInterface
+56Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Core\Authorization;
13+
14+
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
15+
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
16+
17+
/**
18+
* Contains the access verdict and all the related votes.
19+
*
20+
* @author Dany Maillard <danymaillard93b@gmail.com>
21+
* @author Roman JOLY <eltharin18@outlook.fr>
22+
* @author Nicolas Grekas <p@tchwork.com>
23+
*/
24+
final class AccessDecision
25+
{
26+
/**
27+
* @var class-string<AccessDecisionStrategyInterface>|string|null
28+
*/
29+
public ?string $strategy = null;
30+
31+
public bool $isGranted;
32+
33+
/**
34+
* @var Vote[]
35+
*/
36+
public $votes = [];
37+
38+
public function getMessage(): string
39+
{
40+
$message = $this->isGranted ? 'Access granted.' : 'Access denied.';
41+
$access = $this->isGranted ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED;
42+
43+
if ($this->votes) {
44+
foreach ($this->votes as $vote) {
45+
if ($vote->result !== $access) {
46+
continue;
47+
}
48+
foreach ($vote->reasons as $reason) {
49+
$message .= ' '.$reason;
50+
}
51+
}
52+
}
53+
54+
return $message;
55+
}
56+
}

‎src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php
+23-7Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
use Symfony\Component\Security\Core\Authorization\Strategy\AccessDecisionStrategyInterface;
1616
use Symfony\Component\Security\Core\Authorization\Strategy\AffirmativeStrategy;
1717
use Symfony\Component\Security\Core\Authorization\Voter\CacheableVoterInterface;
18+
use Symfony\Component\Security\Core\Authorization\Voter\TraceableVoter;
19+
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
1820
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
1921
use Symfony\Component\Security\Core\Exception\InvalidArgumentException;
2022

@@ -49,35 +51,49 @@ public function __construct(
4951
/**
5052
* @param bool $allowMultipleAttributes Whether to allow passing multiple values to the $attributes array
5153
*/
52-
public function decide(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false): bool
54+
public function decide(TokenInterface $token, array $attributes, mixed $object = null, bool|AccessDecision $accessDecision = new AccessDecision(), bool $allowMultipleAttributes = false): bool
5355
{
56+
if (\is_bool($accessDecision)) {
57+
$allowMultipleAttributes = $accessDecision;
58+
$accessDecision = new AccessDecision();
59+
}
60+
5461
// Special case for AccessListener, do not remove the right side of the condition before 6.0
5562
if (\count($attributes) > 1 && !$allowMultipleAttributes) {
5663
throw new InvalidArgumentException(\sprintf('Passing more than one Security attribute to "%s()" is not supported.', __METHOD__));
5764
}
5865

59-
return $this->strategy->decide(
60-
$this->collectResults($token, $attributes, $object)
66+
$accessDecision->strategy = $this->strategy instanceof \Stringable ? $this->strategy : get_debug_type($this->strategy);
67+
68+
return $accessDecision->isGranted = $this->strategy->decide(
69+
$this->collectResults($token, $attributes, $object, $accessDecision)
6170
);
6271
}
6372

6473
/**
65-
* @return \Traversable<int, int>
74+
* @return \Traversable<VoterInterface::ACCESS_*>
6675
*/
67-
private function collectResults(TokenInterface $token, array $attributes, mixed $object): \Traversable
76+
private function collectResults(TokenInterface $token, array $attributes, mixed $object, AccessDecision $accessDecision): \Traversable
6877
{
6978
foreach ($this->getVoters($attributes, $object) as $voter) {
70-
$result = $voter->vote($token, $object, $attributes);
79+
$vote = new Vote();
80+
$result = $voter->vote($token, $object, $attributes, $vote);
81+
7182
if (!\is_int($result) || !(self::VALID_VOTES[$result] ?? false)) {
7283
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)));
7384
}
7485

86+
$voter = $voter instanceof TraceableVoter ? $voter->getDecoratedVoter() : $voter;
87+
$vote->voter = $voter instanceof \Stringable ? $voter : get_debug_type($voter);
88+
$vote->result = $result;
89+
$accessDecision->votes[] = $vote;
90+
7591
yield $result;
7692
}
7793
}
7894

7995
/**
80-
* @return iterable<mixed, VoterInterface>
96+
* @return iterable<VoterInterface>
8197
*/
8298
private function getVoters(array $attributes, $object = null): iterable
8399
{

‎src/Symfony/Component/Security/Core/Authorization/AccessDecisionManagerInterface.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/Security/Core/Authorization/AccessDecisionManagerInterface.php
+4-3Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ interface AccessDecisionManagerInterface
2323
/**
2424
* Decides whether the access is possible or not.
2525
*
26-
* @param array $attributes An array of attributes associated with the method being invoked
27-
* @param mixed $object The object to secure
26+
* @param array $attributes An array of attributes associated with the method being invoked
27+
* @param mixed $object The object to secure
28+
* @param AccessDecision $accessDecision Should be used to explain the decision
2829
*/
29-
public function decide(TokenInterface $token, array $attributes, mixed $object = null): bool;
30+
public function decide(TokenInterface $token, array $attributes, mixed $object = null/* , AccessDecision $accessDecision = new AccessDecision() */): bool;
3031
}

‎src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php
+2-2Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,14 @@ public function __construct(
3030
) {
3131
}
3232

33-
final public function isGranted(mixed $attribute, mixed $subject = null): bool
33+
final public function isGranted(mixed $attribute, mixed $subject = null, AccessDecision $accessDecision = new AccessDecision()): bool
3434
{
3535
$token = $this->tokenStorage->getToken();
3636

3737
if (!$token || !$token->getUser()) {
3838
$token = new NullToken();
3939
}
4040

41-
return $this->accessDecisionManager->decide($token, [$attribute], $subject);
41+
return $accessDecision->isGranted = $this->accessDecisionManager->decide($token, [$attribute], $subject, $accessDecision);
4242
}
4343
}

‎src/Symfony/Component/Security/Core/Authorization/AuthorizationCheckerInterface.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/Security/Core/Authorization/AuthorizationCheckerInterface.php
+3-2Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ interface AuthorizationCheckerInterface
2121
/**
2222
* Checks if the attribute is granted against the current authentication token and optionally supplied subject.
2323
*
24-
* @param mixed $attribute A single attribute to vote on (can be of any type, string and instance of Expression are supported by the core)
24+
* @param mixed $attribute A single attribute to vote on (can be of any type, string and instance of Expression are supported by the core)
25+
* @param AccessDecision $accessDecision Should be used to explain the decision
2526
*/
26-
public function isGranted(mixed $attribute, mixed $subject = null): bool;
27+
public function isGranted(mixed $attribute, mixed $subject = null/* , AccessDecision $accessDecision = new AccessDecision() */): bool;
2728
}

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.