Skip to content

[Security] Be able to know the reasons of the denied access #35592

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ protected function denyAccessUnlessGranted($attributes, $subject = null, string
$exception = $this->createAccessDeniedException($message);
$exception->setAttributes($attributes);
$exception->setSubject($subject);
$exception->setAccessDecision($this->container->get('security.authorization_checker')->getLastAccessDecision());

throw $exception;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,16 +346,17 @@
<td class="font-normal text-small">attribute {{ voter_detail['attributes'][0] }}</td>
{% endif %}
<td class="font-normal text-small">
{% 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 %}
</td>
<td class="font-normal text-small">{{ voter_detail['vote'].reason|default('~') }}</td>
</tr>
{% endfor %}
</tbody>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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\AccessTrait;
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 <danymaillard93b@gmail.com>
*/
final class AccessDecision
{
use AccessTrait;

/** @var Vote[] */
private $votes = [];

/**
* @param int $access One of the VoterInterface::ACCESS_* constants
* @param Vote[] $votes
*/
private function __construct(int $access, array $votes = [])
{
$this->access = $access;
$this->votes = $votes;
}

/**
* @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);
}

private function getVotesByAccess(int $access): array
{
return array_filter($this->votes, function (Vote $vote) use ($access) { return $vote->getAccess() === $access; });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Symfony\Component\Security\Core\Authorization;

use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Security\Core\Exception\InvalidArgumentException;

Expand Down Expand Up @@ -72,26 +73,27 @@ public function decide(TokenInterface $token, array $attributes, $object = null)
* If all voters abstained from voting, the decision will be based on the
* allowIfAllAbstainDecisions property value (defaults to false).
*/
private function decideAffirmative(TokenInterface $token, array $attributes, $object = null): bool
private function decideAffirmative(TokenInterface $token, array $attributes, $object = null): AccessDecision
{
$votes = [];
$deny = 0;
foreach ($this->voters as $voter) {
$result = $voter->vote($token, $object, $attributes);
$votes[] = $vote = $this->vote($voter, $token, $object, $attributes);

if (VoterInterface::ACCESS_GRANTED === $result) {
return true;
if ($vote->isGranted()) {
return AccessDecision::createGranted($votes);
}

if (VoterInterface::ACCESS_DENIED === $result) {
if ($vote->isDenied()) {
++$deny;
}
}

if ($deny > 0) {
return false;
return AccessDecision::createDenied($votes);
}

return $this->allowIfAllAbstainDecisions;
return $this->decideIfAllAbstainDecisions();
}

/**
Expand All @@ -108,33 +110,37 @@ private function decideAffirmative(TokenInterface $token, array $attributes, $ob
* If all voters abstained from voting, the decision will be based on the
* allowIfAllAbstainDecisions property value (defaults to false).
*/
private function decideConsensus(TokenInterface $token, array $attributes, $object = null): bool
private function decideConsensus(TokenInterface $token, array $attributes, $object = null): AccessDecision
{
$votes = [];
$grant = 0;
$deny = 0;
foreach ($this->voters as $voter) {
$result = $voter->vote($token, $object, $attributes);
$votes[] = $vote = $this->vote($voter, $token, $object, $attributes);

if (VoterInterface::ACCESS_GRANTED === $result) {
if ($vote->isGranted()) {
++$grant;
} elseif (VoterInterface::ACCESS_DENIED === $result) {
} elseif ($vote->isDenied()) {
++$deny;
}
}

if ($grant > $deny) {
return true;
return AccessDecision::createGranted($votes);
}

if ($deny > $grant) {
return false;
return AccessDecision::createDenied($votes);
}

if ($grant > 0) {
return $this->allowIfEqualGrantedDeniedDecisions;
return $this->allowIfEqualGrantedDeniedDecisions
? AccessDecision::createGranted()
: AccessDecision::createDenied()
;
}

return $this->allowIfAllAbstainDecisions;
return $this->decideIfAllAbstainDecisions();
}

/**
Expand All @@ -143,29 +149,30 @@ private function decideConsensus(TokenInterface $token, array $attributes, $obje
* If all voters abstained from voting, the decision will be based on the
* allowIfAllAbstainDecisions property value (defaults to false).
*/
private function decideUnanimous(TokenInterface $token, array $attributes, $object = null): bool
private function decideUnanimous(TokenInterface $token, array $attributes, $object = null): AccessDecision
{
$votes = [];
$grant = 0;
foreach ($this->voters as $voter) {
foreach ($attributes as $attribute) {
$result = $voter->vote($token, $object, [$attribute]);
$votes[] = $vote = $this->vote($voter, $token, $object, [$attribute]);

if (VoterInterface::ACCESS_DENIED === $result) {
return false;
if ($vote->isDenied()) {
return AccessDecision::createDenied($votes);
}

if (VoterInterface::ACCESS_GRANTED === $result) {
if ($vote->isGranted()) {
++$grant;
}
}
}

// no deny votes
if ($grant > 0) {
return true;
return AccessDecision::createGranted($votes);
}

return $this->allowIfAllAbstainDecisions;
return $this->decideIfAllAbstainDecisions();
}

/**
Expand All @@ -191,4 +198,22 @@ private function decidePriority(TokenInterface $token, array $attributes, $objec

return $this->allowIfAllAbstainDecisions;
}

private function decideIfAllAbstainDecisions(): AccessDecision
{
return $this->allowIfAllAbstainDecisions
? AccessDecision::createGranted()
: AccessDecision::createDenied()
;
}

private function vote(VoterInterface $voter, TokenInterface $token, $subject, array $attributes): Vote
{
if (\is_int($vote = $voter->vote($token, $subject, $attributes))) {
trigger_deprecation('symfony/security', 5.1, 'Returning an int from the "%s::vote()" method is deprecated. Return a "%s" object instead.', \get_class($this->voter), Vote::class);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
trigger_deprecation('symfony/security', 5.1, 'Returning an int from the "%s::vote()" method is deprecated. Return a "%s" object instead.', \get_class($this->voter), Vote::class);
trigger_deprecation('symfony/security-core', 5.1, 'Returning an int from the "%s::vote()" method is deprecated, return a "%s" object instead.', \get_class($this->voter), Vote::class);

$vote = Vote::create($vote);
}

return $vote;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ interface AccessDecisionManagerInterface
* @param array $attributes An array of attributes associated with the method being invoked
* @param object $object The object to secure
*
* @return bool true if the access is granted, false otherwise
* @return bool|AccessDecision Returning a boolean is deprecated since Symfony 5.1. Return an AccessDecision object instead.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This interface is not internal, so we should also be BC with the usage of the method AFAIK. I don't think we currently preserve backwards compatibility in e.g. this example:

if (!$accessDecisionManager->decide(...)) {
    throw new AccessDeniedException();
}

If an AccessDecision::createDenied() is returned, the condition !(AccessDecision::createDenied()) is false, causing the access denied exception not to be throw (while it was thrown using the old bool signature).

*/
public function decide(TokenInterface $token, array $attributes, $object = null);
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ class AuthorizationChecker implements AuthorizationCheckerInterface
private $accessDecisionManager;
private $authenticationManager;
private $alwaysAuthenticate;
/** @var AccessDecision */
private $lastAccessDecision;

public function __construct(TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager, AccessDecisionManagerInterface $accessDecisionManager, bool $alwaysAuthenticate = false)
{
Expand All @@ -53,6 +55,19 @@ final public function isGranted($attribute, $subject = null): bool
$this->tokenStorage->setToken($token = $this->authenticationManager->authenticate($token));
}

return $this->accessDecisionManager->decide($token, [$attribute], $subject);
$this->lastAccessDecision = $this->accessDecisionManager->decide($token, [$attribute], $subject);

if (\is_bool($this->lastAccessDecision)) {
trigger_deprecation('symfony/security', 5.1, 'Returning a boolean from the "%s::decide()" method is deprecated. Return an "%s" object instead', \get_class($this->accessDecisionManager), AccessDecision::class);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
trigger_deprecation('symfony/security', 5.1, 'Returning a boolean from the "%s::decide()" method is deprecated. Return an "%s" object instead', \get_class($this->accessDecisionManager), AccessDecision::class);
trigger_deprecation('symfony/security-core', 5.1, 'Returning a boolean from the "%s::decide()" method is deprecated, return an "%s" object instead', \get_class($this->accessDecisionManager), AccessDecision::class);


return $this->lastAccessDecision;
}

return $this->lastAccessDecision->isGranted();
}

public function getLastAccessDecision(): AccessDecision
{
return $this->lastAccessDecision;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Symfony\Component\Security\Core\Authorization;

use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;

/**
Expand Down Expand Up @@ -48,7 +49,7 @@ public function __construct(AccessDecisionManagerInterface $manager)
/**
* {@inheritdoc}
*/
public function decide(TokenInterface $token, array $attributes, $object = null): bool
public function decide(TokenInterface $token, array $attributes, $object = null)
{
$currentDecisionLog = [
'attributes' => $attributes,
Expand All @@ -71,9 +72,9 @@ public function decide(TokenInterface $token, array $attributes, $object = null)
* 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 Vote $vote vote of the voter
*/
public function addVoterVote(VoterInterface $voter, array $attributes, int $vote)
public function addVoterVote(VoterInterface $voter, array $attributes, Vote $vote)
{
$currentLogIndex = \count($this->currentLog) - 1;
$this->currentLog[$currentLogIndex]['voterDetails'][] = [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;

/**
* @author Dany Maillard <danymaillard93b@gmail.com>
*/
trait AccessTrait
{
/** @var int One of the VoterInterface::ACCESS_* constants */
protected $access;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be private.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already discussed here #35592 (comment). Ok for you ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure to understand the rationale there.
If a class uses this trait, it does have write access to the private properties defined by the trait. Using protected would only serve for inheritance purpose, which is not needed here AFAIK.
Am I missing something?


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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ public function __construct(VoterInterface $voter, EventDispatcherInterface $eve
$this->eventDispatcher = $eventDispatcher;
}

public function vote(TokenInterface $token, $subject, array $attributes): int
public function vote(TokenInterface $token, $subject, array $attributes)
{
$result = $this->voter->vote($token, $subject, $attributes);
$result = \is_int($vote = $this->voter->vote($token, $subject, $attributes)) ? Vote::create($vote) : $vote;

$this->eventDispatcher->dispatch(new VoteEvent($this->voter, $subject, $attributes, $result), 'debug.security.authorization.vote');

Expand Down
Loading