Skip to content

Commit 5e5c218

Browse files
[Security] Add ability for voters to explain their vote
1 parent 3205181 commit 5e5c218

22 files changed

+311
-103
lines changed

Authorization/AccessDecision.php

Lines 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 <[email protected]>
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 <[email protected]>
21+
* @author Roman JOLY <[email protected]>
22+
* @author Nicolas Grekas <[email protected]>
23+
*/
24+
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 array $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+
}

Authorization/AccessDecisionManager.php

Lines changed: 32 additions & 8 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

@@ -35,6 +37,7 @@ final class AccessDecisionManager implements AccessDecisionManagerInterface
3537
private array $votersCacheAttributes = [];
3638
private array $votersCacheObject = [];
3739
private AccessDecisionStrategyInterface $strategy;
40+
private array $accessDecisionStack = [];
3841

3942
/**
4043
* @param iterable<mixed, VoterInterface> $voters An array or an iterator of VoterInterface instances
@@ -49,35 +52,56 @@ public function __construct(
4952
/**
5053
* @param bool $allowMultipleAttributes Whether to allow passing multiple values to the $attributes array
5154
*/
52-
public function decide(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false): bool
55+
public function decide(TokenInterface $token, array $attributes, mixed $object = null, bool|AccessDecision|null $accessDecision = null, bool $allowMultipleAttributes = false): bool
5356
{
57+
if (\is_bool($accessDecision)) {
58+
$allowMultipleAttributes = $accessDecision;
59+
$accessDecision = null;
60+
}
61+
5462
// Special case for AccessListener, do not remove the right side of the condition before 6.0
5563
if (\count($attributes) > 1 && !$allowMultipleAttributes) {
5664
throw new InvalidArgumentException(\sprintf('Passing more than one Security attribute to "%s()" is not supported.', __METHOD__));
5765
}
5866

59-
return $this->strategy->decide(
60-
$this->collectResults($token, $attributes, $object)
61-
);
67+
$accessDecision ??= end($this->accessDecisionStack) ?: new AccessDecision();
68+
$this->accessDecisionStack[] = $accessDecision;
69+
70+
$accessDecision->strategy = $this->strategy instanceof \Stringable ? $this->strategy : get_debug_type($this->strategy);
71+
72+
try {
73+
return $accessDecision->isGranted = $this->strategy->decide(
74+
$this->collectResults($token, $attributes, $object, $accessDecision)
75+
);
76+
} finally {
77+
array_pop($this->accessDecisionStack);
78+
}
6279
}
6380

6481
/**
65-
* @return \Traversable<int, int>
82+
* @return \Traversable<int, VoterInterface::ACCESS_*>
6683
*/
67-
private function collectResults(TokenInterface $token, array $attributes, mixed $object): \Traversable
84+
private function collectResults(TokenInterface $token, array $attributes, mixed $object, AccessDecision $accessDecision): \Traversable
6885
{
6986
foreach ($this->getVoters($attributes, $object) as $voter) {
70-
$result = $voter->vote($token, $object, $attributes);
87+
$vote = new Vote();
88+
$result = $voter->vote($token, $object, $attributes, $vote);
89+
7190
if (!\is_int($result) || !(self::VALID_VOTES[$result] ?? false)) {
7291
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)));
7392
}
7493

94+
$voter = $voter instanceof TraceableVoter ? $voter->getDecoratedVoter() : $voter;
95+
$vote->voter = $voter instanceof \Stringable ? $voter : get_debug_type($voter);
96+
$vote->result = $result;
97+
$accessDecision->votes[] = $vote;
98+
7599
yield $result;
76100
}
77101
}
78102

79103
/**
80-
* @return iterable<mixed, VoterInterface>
104+
* @return iterable<int, VoterInterface>
81105
*/
82106
private function getVoters(array $attributes, $object = null): iterable
83107
{

Authorization/AccessDecisionManagerInterface.php

Lines 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|null $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 = null */): bool;
3031
}

Authorization/AuthorizationChecker.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,28 @@
2424
*/
2525
class AuthorizationChecker implements AuthorizationCheckerInterface
2626
{
27+
private array $accessDecisionStack = [];
28+
2729
public function __construct(
2830
private TokenStorageInterface $tokenStorage,
2931
private AccessDecisionManagerInterface $accessDecisionManager,
3032
) {
3133
}
3234

33-
final public function isGranted(mixed $attribute, mixed $subject = null): bool
35+
final public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool
3436
{
3537
$token = $this->tokenStorage->getToken();
3638

3739
if (!$token || !$token->getUser()) {
3840
$token = new NullToken();
3941
}
42+
$accessDecision ??= end($this->accessDecisionStack) ?: new AccessDecision();
43+
$this->accessDecisionStack[] = $accessDecision;
4044

41-
return $this->accessDecisionManager->decide($token, [$attribute], $subject);
45+
try {
46+
return $accessDecision->isGranted = $this->accessDecisionManager->decide($token, [$attribute], $subject, $accessDecision);
47+
} finally {
48+
array_pop($this->accessDecisionStack);
49+
}
4250
}
4351
}

Authorization/AuthorizationCheckerInterface.php

Lines 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|null $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 = null */): bool;
2728
}

Authorization/TraceableAccessDecisionManager.php

Lines changed: 38 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
namespace Symfony\Component\Security\Core\Authorization;
1313

1414
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
15-
use Symfony\Component\Security\Core\Authorization\Strategy\AccessDecisionStrategyInterface;
1615
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
1716

1817
/**
@@ -25,77 +24,68 @@
2524
*/
2625
class TraceableAccessDecisionManager implements AccessDecisionManagerInterface
2726
{
28-
private ?AccessDecisionStrategyInterface $strategy = null;
29-
/** @var iterable<mixed, VoterInterface> */
30-
private iterable $voters = [];
27+
private ?string $strategy = null;
28+
/** @var array<VoterInterface> */
29+
private array $voters = [];
3130
private array $decisionLog = []; // All decision logs
3231
private array $currentLog = []; // Logs being filled in
32+
private array $accessDecisionStack = [];
3333

3434
public function __construct(
3535
private AccessDecisionManagerInterface $manager,
3636
) {
37-
// The strategy and voters are stored in a private properties of the decorated service
38-
if (property_exists($manager, 'strategy')) {
39-
$reflection = new \ReflectionProperty($manager::class, 'strategy');
40-
$this->strategy = $reflection->getValue($manager);
41-
}
42-
if (property_exists($manager, 'voters')) {
43-
$reflection = new \ReflectionProperty($manager::class, 'voters');
44-
$this->voters = $reflection->getValue($manager);
45-
}
4637
}
4738

48-
public function decide(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false): bool
39+
public function decide(TokenInterface $token, array $attributes, mixed $object = null, bool|AccessDecision|null $accessDecision = null, bool $allowMultipleAttributes = false): bool
4940
{
50-
$currentDecisionLog = [
41+
if (\is_bool($accessDecision)) {
42+
$allowMultipleAttributes = $accessDecision;
43+
$accessDecision = null;
44+
}
45+
46+
// Using a stack since decide can be called by voters
47+
$this->currentLog[] = [
5148
'attributes' => $attributes,
5249
'object' => $object,
5350
'voterDetails' => [],
5451
];
5552

56-
$this->currentLog[] = &$currentDecisionLog;
57-
58-
$result = $this->manager->decide($token, $attributes, $object, $allowMultipleAttributes);
59-
60-
$currentDecisionLog['result'] = $result;
61-
62-
$this->decisionLog[] = array_pop($this->currentLog); // Using a stack since decide can be called by voters
63-
64-
return $result;
53+
$accessDecision ??= end($this->accessDecisionStack) ?: new AccessDecision();
54+
$this->accessDecisionStack[] = $accessDecision;
55+
56+
try {
57+
return $accessDecision->isGranted = $this->manager->decide($token, $attributes, $object, $accessDecision);
58+
} finally {
59+
$this->strategy = $accessDecision->strategy;
60+
$currentLog = array_pop($this->currentLog);
61+
if (isset($accessDecision->isGranted)) {
62+
$currentLog['result'] = $accessDecision->isGranted;
63+
}
64+
$this->decisionLog[] = $currentLog;
65+
}
6566
}
6667

67-
/**
68-
* Adds voter vote and class to the voter details.
69-
*
70-
* @param array $attributes attributes used for the vote
71-
* @param int $vote vote of the voter
72-
*/
73-
public function addVoterVote(VoterInterface $voter, array $attributes, int $vote): void
68+
public function addVoterVote(VoterInterface $voter, array $attributes, int $vote, array $reasons = []): void
7469
{
7570
$currentLogIndex = \count($this->currentLog) - 1;
7671
$this->currentLog[$currentLogIndex]['voterDetails'][] = [
7772
'voter' => $voter,
7873
'attributes' => $attributes,
7974
'vote' => $vote,
75+
'reasons' => $reasons,
8076
];
77+
$this->voters[$voter::class] = $voter;
8178
}
8279

8380
public function getStrategy(): string
8481
{
85-
if (null === $this->strategy) {
86-
return '-';
87-
}
88-
if ($this->strategy instanceof \Stringable) {
89-
return (string) $this->strategy;
90-
}
91-
92-
return get_debug_type($this->strategy);
82+
return $this->strategy ?? '-';
9383
}
9484

9585
/**
96-
* @return iterable<mixed, VoterInterface>
86+
* @return array<VoterInterface>
9787
*/
98-
public function getVoters(): iterable
88+
public function getVoters(): array
9989
{
10090
return $this->voters;
10191
}
@@ -104,4 +94,11 @@ public function getDecisionLog(): array
10494
{
10595
return $this->decisionLog;
10696
}
97+
98+
public function reset(): void
99+
{
100+
$this->strategy = null;
101+
$this->voters = [];
102+
$this->decisionLog = [];
103+
}
107104
}

Authorization/UserAuthorizationChecker.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ public function __construct(
2424
) {
2525
}
2626

27-
public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null): bool
27+
public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool
2828
{
29-
return $this->accessDecisionManager->decide(new UserAuthorizationCheckerToken($user), [$attribute], $subject);
29+
return $this->accessDecisionManager->decide(new UserAuthorizationCheckerToken($user), [$attribute], $subject, $accessDecision);
3030
}
3131
}

Authorization/UserAuthorizationCheckerInterface.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ interface UserAuthorizationCheckerInterface
2323
/**
2424
* Checks if the attribute is granted against the user and optionally supplied subject.
2525
*
26-
* @param mixed $attribute A single attribute to vote on (can be of any type, string and instance of Expression are supported by the core)
26+
* @param mixed $attribute A single attribute to vote on (can be of any type, string and instance of Expression are supported by the core)
27+
* @param AccessDecision|null $accessDecision Should be used to explain the decision
2728
*/
28-
public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null): bool;
29+
public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool;
2930
}

0 commit comments

Comments
 (0)