[Security][SecurityBundle] Add voter individual decisions to profiler

This commit is contained in:
Laurent VOULLEMIER 2018-10-18 19:20:02 +02:00
parent edcd627230
commit 8abb05607b
17 changed files with 831 additions and 21 deletions

View File

@ -16,6 +16,7 @@ CHANGELOG
* Deprecated the `simple_form` and `simple_preauth` authentication listeners, use Guard instead.
* Deprecated the `SimpleFormFactory` and `SimplePreAuthenticationFactory` classes, use Guard instead.
* Added `port` in access_control
* Added individual voter decisions to the profiler
4.1.0
-----

View File

@ -20,6 +20,7 @@ use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
use Symfony\Component\Security\Core\Authorization\TraceableAccessDecisionManager;
use Symfony\Component\Security\Core\Authorization\Voter\TraceableVoter;
use Symfony\Component\Security\Core\Role\Role;
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
use Symfony\Component\Security\Core\Role\SwitchUserRole;
@ -136,12 +137,33 @@ class SecurityDataCollector extends DataCollector implements LateDataCollectorIn
// collect voters and access decision manager information
if ($this->accessDecisionManager instanceof TraceableAccessDecisionManager) {
$this->data['access_decision_log'] = $this->accessDecisionManager->getDecisionLog();
$this->data['voter_strategy'] = $this->accessDecisionManager->getStrategy();
foreach ($this->accessDecisionManager->getVoters() as $voter) {
if ($voter instanceof TraceableVoter) {
$voter = $voter->getDecoratedVoter();
}
$this->data['voters'][] = $this->hasVarDumper ? new ClassStub(\get_class($voter)) : \get_class($voter);
}
// collect voter details
$decisionLog = $this->accessDecisionManager->getDecisionLog();
foreach ($decisionLog as $key => $log) {
$decisionLog[$key]['voter_details'] = array();
foreach ($log['voterDetails'] as $voterDetail) {
$voterClass = \get_class($voterDetail['voter']);
$classData = $this->hasVarDumper ? new ClassStub($voterClass) : $voterClass;
$decisionLog[$key]['voter_details'][] = array(
'class' => $classData,
'attributes' => $voterDetail['attributes'], // Only displayed for unanimous strategy
'vote' => $voterDetail['vote'],
);
}
unset($decisionLog[$key]['voterDetails']);
}
$this->data['access_decision_log'] = $decisionLog;
} else {
$this->data['access_decision_log'] = array();
$this->data['voter_strategy'] = 'unknown';

View File

@ -16,6 +16,8 @@ use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\Compiler\PriorityTaggedServiceTrait;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\LogicException;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\Security\Core\Authorization\Voter\TraceableVoter;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
/**
@ -41,13 +43,27 @@ class AddSecurityVotersPass implements CompilerPassInterface
throw new LogicException('No security voters found. You need to tag at least one with "security.voter".');
}
$debug = $container->getParameter('kernel.debug');
foreach ($voters as $voter) {
$definition = $container->getDefinition((string) $voter);
$voterServiceId = (string) $voter;
$definition = $container->getDefinition($voterServiceId);
$class = $container->getParameterBag()->resolveValue($definition->getClass());
if (!is_a($class, VoterInterface::class, true)) {
throw new LogicException(sprintf('%s must implement the %s when used as a voter.', $class, VoterInterface::class));
}
if ($debug) {
// Decorate original voters with TraceableVoter
$debugVoterServiceId = '.debug.security.voter.'.$voterServiceId;
$container
->register($debugVoterServiceId, TraceableVoter::class)
->setDecoratedService($voterServiceId)
->addArgument(new Reference($debugVoterServiceId.'.inner'))
->addArgument(new Reference('event_dispatcher'));
}
}
$adm = $container->getDefinition('security.access.decision_manager');

View File

@ -0,0 +1,48 @@
<?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\Bundle\SecurityBundle\EventListener;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Authorization\TraceableAccessDecisionManager;
use Symfony\Component\Security\Core\Event\VoteEvent;
/**
* Listen to vote events from traceable voters.
*
* @author Laurent VOULLEMIER <laurent.voullemier@gmail.com>
*
* @internal
*/
class VoteListener implements EventSubscriberInterface
{
private $traceableAccessDecisionManager;
public function __construct(TraceableAccessDecisionManager $traceableAccessDecisionManager)
{
$this->traceableAccessDecisionManager = $traceableAccessDecisionManager;
}
/**
* Event dispatched by a voter during access manager decision.
*
* @param VoteEvent $event event with voter data
*/
public function onVoterVote(VoteEvent $event)
{
$this->traceableAccessDecisionManager->addVoterVote($event->getVoter(), $event->getAttributes(), $event->getVote());
}
public static function getSubscribedEvents()
{
return array('debug.security.authorization.vote' => 'onVoterVote');
}
}

View File

@ -11,6 +11,11 @@
<argument type="service" id="debug.security.access.decision_manager.inner" />
</service>
<service id="debug.security.voter.vote_listener" class="Symfony\Bundle\SecurityBundle\EventListener\VoteListener">
<tag name="kernel.event_subscriber" />
<argument type="service" id="debug.security.access.decision_manager" />
</service>
<service id="debug.security.firewall" class="Symfony\Bundle\SecurityBundle\Debug\TraceableFirewallListener">
<tag name="kernel.event_subscriber" />
<argument type="service" id="security.firewall.map" />

View File

@ -307,7 +307,7 @@
<tbody>
{% for decision in collector.accessDecisionLog %}
<tr>
<tr class="voter_result">
<td class="font-normal text-small text-muted nowrap">{{ loop.index }}</td>
<td class="font-normal">
{{ decision.result
@ -331,6 +331,40 @@
</td>
<td>{{ profiler_dump(decision.seek('object')) }}</td>
</tr>
<tr class="voter_details">
<td></td>
<td colspan="3">
{% if decision.voter_details is not empty %}
{% set voter_details_id = 'voter-details-' ~ loop.index %}
<div id="{{ voter_details_id }}" class="sf-toggle-content sf-toggle-hidden">
<table>
<tbody>
{% for voter_detail in decision.voter_details %}
<tr>
<td class="font-normal">{{ profiler_dump(voter_detail['class']) }}</td>
{% if collector.voterStrategy == constant('Symfony\\Component\\Security\\Core\\Authorization\\AccessDecisionManager::STRATEGY_UNANIMOUS') %}
<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') %}
ACCESS GRANTED
{% elseif voter_detail['vote'] == 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') %}
ACCESS DENIED
{% else %}
unknown ({{ voter_detail['vote'] }})
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a class="btn btn-link text-small sf-toggle" data-toggle-selector="#{{ voter_details_id }}" data-toggle-alt-content="Hide voter details">Show voter details</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>

View File

@ -17,10 +17,15 @@ use Symfony\Bundle\SecurityBundle\Debug\TraceableFirewallListener;
use Symfony\Bundle\SecurityBundle\Security\FirewallConfig;
use Symfony\Bundle\SecurityBundle\Security\FirewallMap;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManager;
use Symfony\Component\Security\Core\Authorization\TraceableAccessDecisionManager;
use Symfony\Component\Security\Core\Authorization\Voter\TraceableVoter;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Security\Core\Role\Role;
use Symfony\Component\Security\Core\Role\RoleHierarchy;
use Symfony\Component\Security\Core\Role\SwitchUserRole;
@ -221,6 +226,137 @@ class SecurityDataCollectorTest extends TestCase
$this->addToAssertionCount(1);
}
public function providerCollectDecisionLog(): \Generator
{
$voter1 = $this->getMockBuilder(VoterInterface::class)->getMockForAbstractClass();
$voter2 = $this->getMockBuilder(VoterInterface::class)->getMockForAbstractClass();
$eventDispatcher = $this->getMockBuilder(EventDispatcherInterface::class)->getMockForAbstractClass();
$decoratedVoter1 = new TraceableVoter($voter1, $eventDispatcher);
$decoratedVoter2 = new TraceableVoter($voter2, $eventDispatcher);
yield array(
AccessDecisionManager::STRATEGY_AFFIRMATIVE,
array(array(
'attributes' => array('view'),
'object' => new \stdClass(),
'result' => true,
'voterDetails' => array(
array('voter' => $voter1, 'attributes' => array('view'), 'vote' => VoterInterface::ACCESS_ABSTAIN),
array('voter' => $voter2, 'attributes' => array('view'), 'vote' => VoterInterface::ACCESS_ABSTAIN),
),
)),
array($decoratedVoter1, $decoratedVoter1),
array(\get_class($voter1), \get_class($voter2)),
array(array(
'attributes' => array('view'),
'object' => new \stdClass(),
'result' => true,
'voter_details' => array(
array('class' => \get_class($voter1), 'attributes' => array('view'), 'vote' => VoterInterface::ACCESS_ABSTAIN),
array('class' => \get_class($voter2), 'attributes' => array('view'), 'vote' => VoterInterface::ACCESS_ABSTAIN),
),
)),
);
yield array(
AccessDecisionManager::STRATEGY_UNANIMOUS,
array(
array(
'attributes' => array('view', 'edit'),
'object' => new \stdClass(),
'result' => false,
'voterDetails' => array(
array('voter' => $voter1, 'attributes' => array('view'), 'vote' => VoterInterface::ACCESS_DENIED),
array('voter' => $voter1, 'attributes' => array('edit'), 'vote' => VoterInterface::ACCESS_DENIED),
array('voter' => $voter2, 'attributes' => array('view'), 'vote' => VoterInterface::ACCESS_GRANTED),
array('voter' => $voter2, 'attributes' => array('edit'), 'vote' => VoterInterface::ACCESS_GRANTED),
),
),
array(
'attributes' => array('update'),
'object' => new \stdClass(),
'result' => true,
'voterDetails' => array(
array('voter' => $voter1, 'attributes' => array('update'), 'vote' => VoterInterface::ACCESS_GRANTED),
array('voter' => $voter2, 'attributes' => array('update'), 'vote' => VoterInterface::ACCESS_GRANTED),
),
),
),
array($decoratedVoter1, $decoratedVoter1),
array(\get_class($voter1), \get_class($voter2)),
array(
array(
'attributes' => array('view', 'edit'),
'object' => new \stdClass(),
'result' => false,
'voter_details' => array(
array('class' => \get_class($voter1), 'attributes' => array('view'), 'vote' => VoterInterface::ACCESS_DENIED),
array('class' => \get_class($voter1), 'attributes' => array('edit'), 'vote' => VoterInterface::ACCESS_DENIED),
array('class' => \get_class($voter2), 'attributes' => array('view'), 'vote' => VoterInterface::ACCESS_GRANTED),
array('class' => \get_class($voter2), 'attributes' => array('edit'), 'vote' => VoterInterface::ACCESS_GRANTED),
),
),
array(
'attributes' => array('update'),
'object' => new \stdClass(),
'result' => true,
'voter_details' => array(
array('class' => \get_class($voter1), 'attributes' => array('update'), 'vote' => VoterInterface::ACCESS_GRANTED),
array('class' => \get_class($voter2), 'attributes' => array('update'), 'vote' => VoterInterface::ACCESS_GRANTED),
),
),
),
);
}
/**
* Test the returned data when AccessDecisionManager is a TraceableAccessDecisionManager.
*
* @param string $strategy strategy returned by the AccessDecisionManager
* @param array $voters voters returned by AccessDecisionManager
* @param array $decisionLog log of the votes and final decisions from AccessDecisionManager
* @param array $expectedVoterClasses expected voter classes returned by the collector
* @param array $expectedDecisionLog expected decision log returned by the collector
*
* @dataProvider providerCollectDecisionLog
*/
public function testCollectDecisionLog(string $strategy, array $decisionLog, array $voters, array $expectedVoterClasses, array $expectedDecisionLog): void
{
$accessDecisionManager = $this
->getMockBuilder(TraceableAccessDecisionManager::class)
->disableOriginalConstructor()
->setMethods(array('getStrategy', 'getVoters', 'getDecisionLog'))
->getMock();
$accessDecisionManager
->expects($this->any())
->method('getStrategy')
->willReturn($strategy);
$accessDecisionManager
->expects($this->any())
->method('getVoters')
->willReturn($voters);
$accessDecisionManager
->expects($this->any())
->method('getDecisionLog')
->willReturn($decisionLog);
$dataCollector = new SecurityDataCollector(null, null, null, $accessDecisionManager);
$dataCollector->collect($this->getRequest(), $this->getResponse());
$this->assertEquals($dataCollector->getAccessDecisionLog(), $expectedDecisionLog, 'Wrong value returned by getAccessDecisionLog');
$this->assertSame(
array_map(function ($classStub) { return (string) $classStub; }, $dataCollector->getVoters()),
$expectedVoterClasses,
'Wrong value returned by getVoters'
);
$this->assertSame($dataCollector->getVoterStrategy(), $strategy, 'Wrong value returned by getVoterStrategy');
}
public function provideRoles()
{
return array(

View File

@ -14,6 +14,7 @@ namespace Symfony\Bundle\SecurityBundle\Tests\DependencyInjection\Compiler;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\AddSecurityVotersPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManager;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
@ -39,6 +40,8 @@ class AddSecurityVotersPassTest extends TestCase
public function testThatSecurityVotersAreProcessedInPriorityOrder()
{
$container = new ContainerBuilder();
$container->setParameter('kernel.debug', false);
$container
->register('security.access.decision_manager', AccessDecisionManager::class)
->addArgument(array())
@ -69,6 +72,68 @@ class AddSecurityVotersPassTest extends TestCase
$this->assertCount(4, $refs);
}
/**
* Test that in debug mode, voters are correctly decorated.
*/
public function testThatVotersAreDecoratedInDebugMode(): void
{
$container = new ContainerBuilder();
$voterDef1 = new Definition(Voter::class);
$voterDef1->addTag('security.voter');
$container->setDefinition('voter1', $voterDef1);
$voterDef2 = new Definition(Voter::class);
$voterDef2->addTag('security.voter');
$container->setDefinition('voter2', $voterDef2);
$container
->register('security.access.decision_manager', AccessDecisionManager::class)
->addArgument(array($voterDef1, $voterDef2));
$container->setParameter('kernel.debug', true);
$compilerPass = new AddSecurityVotersPass();
$compilerPass->process($container);
$def1 = $container->getDefinition('.debug.security.voter.voter1');
$this->assertEquals(array('voter1', null, 0), $def1->getDecoratedService(), 'voter1: wrong return from getDecoratedService');
$this->assertEquals(new Reference('.debug.security.voter.voter1.inner'), $def1->getArgument(0), 'voter1: wrong decorator argument');
$def2 = $container->getDefinition('.debug.security.voter.voter2');
$this->assertEquals(array('voter2', null, 0), $def2->getDecoratedService(), 'voter2: wrong return from getDecoratedService');
$this->assertEquals(new Reference('.debug.security.voter.voter2.inner'), $def2->getArgument(0), 'voter2: wrong decorator argument');
$voters = $container->findTaggedServiceIds('security.voter');
$this->assertCount(2, $voters, 'Incorrect count of voters');
}
/**
* Test that voters are not decorated if the application is not in debug mode.
*/
public function testThatVotersAreNotDecoratedWithoutDebugMode(): void
{
$container = new ContainerBuilder();
$container->setParameter('kernel.debug', false);
$voterDef1 = new Definition(Voter::class);
$voterDef1->addTag('security.voter');
$container->setDefinition('voter1', $voterDef1);
$voterDef2 = new Definition(Voter::class);
$voterDef2->addTag('security.voter');
$container->setDefinition('voter2', $voterDef2);
$container
->register('security.access.decision_manager', AccessDecisionManager::class)
->addArgument(array($voterDef1, $voterDef2));
$compilerPass = new AddSecurityVotersPass();
$compilerPass->process($container);
$this->assertFalse($container->has('debug.security.voter.voter1'), 'voter1 should not be decorated');
$this->assertFalse($container->has('debug.security.voter.voter2'), 'voter2 should not be decorated');
}
/**
* @expectedException \Symfony\Component\DependencyInjection\Exception\LogicException
* @expectedExceptionMessage stdClass must implement the Symfony\Component\Security\Core\Authorization\Voter\VoterInterface when used as a voter.
@ -76,6 +141,7 @@ class AddSecurityVotersPassTest extends TestCase
public function testVoterMissingInterface()
{
$container = new ContainerBuilder();
$container->setParameter('kernel.debug', false);
$container
->register('security.access.decision_manager', AccessDecisionManager::class)
->addArgument(array())

View File

@ -511,6 +511,8 @@ abstract class CompleteConfigurationTest extends TestCase
$file .= '.'.$this->getFileExtension();
$container = new ContainerBuilder();
$container->setParameter('kernel.debug', false);
$security = new SecurityExtension();
$container->registerExtension($security);

View File

@ -346,6 +346,8 @@ class SecurityExtensionTest extends TestCase
protected function getRawContainer()
{
$container = new ContainerBuilder();
$container->setParameter('kernel.debug', false);
$security = new SecurityExtension();
$container->registerExtension($security);

View File

@ -0,0 +1,40 @@
<?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\Bundle\SecurityBundle\Tests\EventListener;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\EventListener\VoteListener;
use Symfony\Component\Security\Core\Authorization\TraceableAccessDecisionManager;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Security\Core\Event\VoteEvent;
class VoteListenerTest extends TestCase
{
public function testOnVoterVote()
{
$voter = $this->createMock(VoterInterface::class);
$traceableAccessDecisionManager = $this
->getMockBuilder(TraceableAccessDecisionManager::class)
->disableOriginalConstructor()
->setMethods(array('addVoterVote'))
->getMock();
$traceableAccessDecisionManager
->expects($this->once())
->method('addVoterVote')
->with($voter, array('myattr1', 'myattr2'), VoterInterface::ACCESS_GRANTED);
$sut = new VoteListener($traceableAccessDecisionManager);
$sut->onVoterVote(new VoteEvent($voter, 'mysubject', array('myattr1', 'myattr2'), VoterInterface::ACCESS_GRANTED));
}
}

View File

@ -1027,6 +1027,31 @@ table.logs .metadata {
hyphens: auto;
}
{# Security panel
========================================================================= #}
#collector-content .decision-log .voter_result td {
border-top-width: 1px;
border-bottom-width: 0;
padding-bottom: 0;
}
#collector-content .decision-log .voter_details td {
border-top-width: 0;
border-bottom-width: 1px;
padding-bottom: 0;
}
#collector-content .decision-log .voter_details table {
border: 0;
margin: 0;
box-shadow: unset;
}
#collector-content .decision-log .voter_details table td {
border: 0;
padding: 0 0 8px 0;
}
{# Validator panel
========================================================================= #}

View File

@ -27,7 +27,8 @@ class TraceableAccessDecisionManager implements AccessDecisionManagerInterface
private $manager;
private $strategy;
private $voters = array();
private $decisionLog = array();
private $decisionLog = array(); // All decision logs
private $currentLog = array(); // Logs being filled in
public function __construct(AccessDecisionManagerInterface $manager)
{
@ -49,17 +50,40 @@ class TraceableAccessDecisionManager implements AccessDecisionManagerInterface
*/
public function decide(TokenInterface $token, array $attributes, $object = null)
{
$result = $this->manager->decide($token, $attributes, $object);
$this->decisionLog[] = array(
$currentDecisionLog = array(
'attributes' => $attributes,
'object' => $object,
'result' => $result,
'voterDetails' => array(),
);
$this->currentLog[] = &$currentDecisionLog;
$result = $this->manager->decide($token, $attributes, $object);
$currentDecisionLog['result'] = $result;
$this->decisionLog[] = array_pop($this->currentLog); // Using a stack since decide can be called by voters
return $result;
}
/**
* Adds voter vote and class to the voter details.
*
* @param VoterInterface $voter voter
* @param array $attributes attributes used for the vote
* @param int $vote vote of the voter
*/
public function addVoterVote(VoterInterface $voter, array $attributes, int $vote)
{
$currentLogIndex = \count($this->currentLog) - 1;
$this->currentLog[$currentLogIndex]['voterDetails'][] = array(
'voter' => $voter,
'attributes' => $attributes,
'vote' => $vote,
);
}
/**
* @return string
*/

View File

@ -0,0 +1,49 @@
<?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;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Event\VoteEvent;
/**
* Decorates voter classes to send result events.
*
* @author Laurent VOULLEMIER <laurent.voullemier@gmail.com>
*
* @internal
*/
class TraceableVoter implements VoterInterface
{
private $voter;
private $eventDispatcher;
public function __construct(VoterInterface $voter, EventDispatcherInterface $eventDispatcher)
{
$this->voter = $voter;
$this->eventDispatcher = $eventDispatcher;
}
public function vote(TokenInterface $token, $subject, array $attributes)
{
$result = $this->voter->vote($token, $subject, $attributes);
$this->eventDispatcher->dispatch('debug.security.authorization.vote', new VoteEvent($this->voter, $subject, $attributes, $result));
return $result;
}
public function getDecoratedVoter(): VoterInterface
{
return $this->voter;
}
}

View File

@ -0,0 +1,58 @@
<?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\Event;
use Symfony\Component\EventDispatcher\Event;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
/**
* This event is dispatched on voter vote.
*
* @author Laurent VOULLEMIER <laurent.voullemier@gmail.com>
*
* @internal
*/
class VoteEvent extends Event
{
private $voter;
private $subject;
private $attributes;
private $vote;
public function __construct(VoterInterface $voter, $subject, array $attributes, int $vote)
{
$this->voter = $voter;
$this->subject = $subject;
$this->attributes = $attributes;
$this->vote = $vote;
}
public function getVoter(): VoterInterface
{
return $this->voter;
}
public function getSubject()
{
return $this->subject;
}
public function getAttributes(): array
{
return $this->attributes;
}
public function getVote(): int
{
return $this->vote;
}
}

View File

@ -16,31 +16,162 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManager;
use Symfony\Component\Security\Core\Authorization\DebugAccessDecisionManager;
use Symfony\Component\Security\Core\Authorization\TraceableAccessDecisionManager;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
class TraceableAccessDecisionManagerTest extends TestCase
{
/**
* @dataProvider provideObjectsAndLogs
*/
public function testDecideLog($expectedLog, $object)
public function testDecideLog(array $expectedLog, array $attributes, $object, array $voterVotes, bool $result)
{
$adm = new TraceableAccessDecisionManager(new AccessDecisionManager());
$adm->decide($this->getMockBuilder(TokenInterface::class)->getMock(), array('ATTRIBUTE_1'), $object);
$token = $this->createMock(TokenInterface::class);
$this->assertSame($expectedLog, $adm->getDecisionLog());
$admMock = $this
->getMockBuilder(AccessDecisionManager::class)
->setMethods(array('decide'))
->getMock();
$adm = new TraceableAccessDecisionManager($admMock);
$admMock
->expects($this->once())
->method('decide')
->with($token, $attributes, $object)
->willReturnCallback(function ($token, $attributes, $object) use ($voterVotes, $adm, $result) {
foreach ($voterVotes as $voterVote) {
list($voter, $vote) = $voterVote;
$adm->addVoterVote($voter, $attributes, $vote);
}
return $result;
})
;
$adm->decide($token, $attributes, $object);
$this->assertEquals($expectedLog, $adm->getDecisionLog());
}
public function provideObjectsAndLogs()
public function provideObjectsAndLogs(): \Generator
{
$object = new \stdClass();
$voter1 = $this->getMockForAbstractClass(VoterInterface::class);
$voter2 = $this->getMockForAbstractClass(VoterInterface::class);
yield array(array(array('attributes' => array('ATTRIBUTE_1'), 'object' => null, 'result' => false)), null);
yield array(array(array('attributes' => array('ATTRIBUTE_1'), 'object' => true, 'result' => false)), true);
yield array(array(array('attributes' => array('ATTRIBUTE_1'), 'object' => 'jolie string', 'result' => false)), 'jolie string');
yield array(array(array('attributes' => array('ATTRIBUTE_1'), 'object' => 12345, 'result' => false)), 12345);
yield array(array(array('attributes' => array('ATTRIBUTE_1'), 'object' => $x = fopen(__FILE__, 'r'), 'result' => false)), $x);
yield array(array(array('attributes' => array('ATTRIBUTE_1'), 'object' => $x = array(), 'result' => false)), $x);
yield array(array(array('attributes' => array('ATTRIBUTE_1'), 'object' => $object, 'result' => false)), $object);
yield array(
array(array(
'attributes' => array('ATTRIBUTE_1'),
'object' => null,
'result' => true,
'voterDetails' => array(
array('voter' => $voter1, 'attributes' => array('ATTRIBUTE_1'), 'vote' => VoterInterface::ACCESS_GRANTED),
array('voter' => $voter2, 'attributes' => array('ATTRIBUTE_1'), 'vote' => VoterInterface::ACCESS_GRANTED),
),
)),
array('ATTRIBUTE_1'),
null,
array(
array($voter1, VoterInterface::ACCESS_GRANTED),
array($voter2, VoterInterface::ACCESS_GRANTED),
),
true,
);
yield array(
array(array(
'attributes' => array('ATTRIBUTE_1', 'ATTRIBUTE_2'),
'object' => true,
'result' => false,
'voterDetails' => array(
array('voter' => $voter1, 'attributes' => array('ATTRIBUTE_1', 'ATTRIBUTE_2'), 'vote' => VoterInterface::ACCESS_ABSTAIN),
array('voter' => $voter2, 'attributes' => array('ATTRIBUTE_1', 'ATTRIBUTE_2'), 'vote' => VoterInterface::ACCESS_GRANTED),
),
)),
array('ATTRIBUTE_1', 'ATTRIBUTE_2'),
true,
array(
array($voter1, VoterInterface::ACCESS_ABSTAIN),
array($voter2, VoterInterface::ACCESS_GRANTED),
),
false,
);
yield array(
array(array(
'attributes' => array(null),
'object' => 'jolie string',
'result' => false,
'voterDetails' => array(
array('voter' => $voter1, 'attributes' => array(null), 'vote' => VoterInterface::ACCESS_ABSTAIN),
array('voter' => $voter2, 'attributes' => array(null), 'vote' => VoterInterface::ACCESS_DENIED),
),
)),
array(null),
'jolie string',
array(
array($voter1, VoterInterface::ACCESS_ABSTAIN),
array($voter2, VoterInterface::ACCESS_DENIED),
),
false,
);
yield array(
array(array(
'attributes' => array(12),
'object' => 12345,
'result' => true,
'voterDetails' => array(),
)),
'attributes' => array(12),
12345,
array(),
true,
);
yield array(
array(array(
'attributes' => array(new \stdClass()),
'object' => $x = fopen(__FILE__, 'rb'),
'result' => true,
'voterDetails' => array(),
)),
array(new \stdClass()),
$x,
array(),
true,
);
yield array(
array(array(
'attributes' => array('ATTRIBUTE_2'),
'object' => $x = array(),
'result' => false,
'voterDetails' => array(
array('voter' => $voter1, 'attributes' => array('ATTRIBUTE_2'), 'vote' => VoterInterface::ACCESS_ABSTAIN),
array('voter' => $voter2, 'attributes' => array('ATTRIBUTE_2'), 'vote' => VoterInterface::ACCESS_ABSTAIN),
),
)),
array('ATTRIBUTE_2'),
$x,
array(
array($voter1, VoterInterface::ACCESS_ABSTAIN),
array($voter2, VoterInterface::ACCESS_ABSTAIN),
),
false,
);
yield array(
array(array(
'attributes' => array(12.13),
'object' => new \stdClass(),
'result' => false,
'voterDetails' => array(
array('voter' => $voter1, 'attributes' => array(12.13), 'vote' => VoterInterface::ACCESS_DENIED),
array('voter' => $voter2, 'attributes' => array(12.13), 'vote' => VoterInterface::ACCESS_DENIED),
),
)),
array(12.13),
new \stdClass(),
array(
array($voter1, VoterInterface::ACCESS_DENIED),
array($voter2, VoterInterface::ACCESS_DENIED),
),
false,
);
}
public function testDebugAccessDecisionManagerAliasExistsForBC()
@ -49,4 +180,101 @@ class TraceableAccessDecisionManagerTest extends TestCase
$this->assertInstanceOf(DebugAccessDecisionManager::class, $adm, 'For BC, TraceableAccessDecisionManager must be an instance of DebugAccessDecisionManager');
}
/**
* Tests decision log returned when a voter call decide method of AccessDecisionManager.
*/
public function testAccessDecisionManagerCalledByVoter()
{
$voter1 = $this
->getMockBuilder(VoterInterface::class)
->setMethods(array('vote'))
->getMock();
$voter2 = $this
->getMockBuilder(VoterInterface::class)
->setMethods(array('vote'))
->getMock();
$voter3 = $this
->getMockBuilder(VoterInterface::class)
->setMethods(array('vote'))
->getMock();
$sut = new TraceableAccessDecisionManager(new AccessDecisionManager(array($voter1, $voter2, $voter3)));
$voter1
->expects($this->any())
->method('vote')
->willReturnCallback(function (TokenInterface $token, $subject, array $attributes) use ($sut, $voter1) {
$vote = \in_array('attr1', $attributes) ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_ABSTAIN;
$sut->addVoterVote($voter1, $attributes, $vote);
return $vote;
});
$voter2
->expects($this->any())
->method('vote')
->willReturnCallback(function (TokenInterface $token, $subject, array $attributes) use ($sut, $voter2) {
if (\in_array('attr2', $attributes)) {
$vote = null == $subject ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED;
} else {
$vote = VoterInterface::ACCESS_ABSTAIN;
}
$sut->addVoterVote($voter2, $attributes, $vote);
return $vote;
});
$voter3
->expects($this->any())
->method('vote')
->willReturnCallback(function (TokenInterface $token, $subject, array $attributes) use ($sut, $voter3) {
if (\in_array('attr2', $attributes) && $subject) {
$vote = $sut->decide($token, $attributes);
} else {
$vote = VoterInterface::ACCESS_ABSTAIN;
}
$sut->addVoterVote($voter3, $attributes, $vote);
return $vote;
});
$token = $this->getMockBuilder(TokenInterface::class)->getMock();
$sut->decide($token, array('attr1'), null);
$sut->decide($token, array('attr2'), $obj = new \stdClass());
$this->assertEquals(array(
array(
'attributes' => array('attr1'),
'object' => null,
'voterDetails' => array(
array('voter' => $voter1, 'attributes' => array('attr1'), 'vote' => VoterInterface::ACCESS_GRANTED),
),
'result' => true,
),
array(
'attributes' => array('attr2'),
'object' => null,
'voterDetails' => array(
array('voter' => $voter1, 'attributes' => array('attr2'), 'vote' => VoterInterface::ACCESS_ABSTAIN),
array('voter' => $voter2, 'attributes' => array('attr2'), 'vote' => VoterInterface::ACCESS_GRANTED),
),
'result' => true,
),
array(
'attributes' => array('attr2'),
'object' => $obj,
'voterDetails' => array(
array('voter' => $voter1, 'attributes' => array('attr2'), 'vote' => VoterInterface::ACCESS_ABSTAIN),
array('voter' => $voter2, 'attributes' => array('attr2'), 'vote' => VoterInterface::ACCESS_DENIED),
array('voter' => $voter3, 'attributes' => array('attr2'), 'vote' => VoterInterface::ACCESS_GRANTED),
),
'result' => true,
),
), $sut->getDecisionLog());
}
}

View File

@ -0,0 +1,54 @@
<?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\Tests\Authorization\Voter;
use PHPUnit\Framework\TestCase;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\TraceableVoter;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Security\Core\Event\VoteEvent;
class TraceableVoterTest extends TestCase
{
public function testGetDecoratedVoterClass()
{
$voter = $this->getMockBuilder(VoterInterface::class)->getMockForAbstractClass();
$sut = new TraceableVoter($voter, $this->getMockBuilder(EventDispatcherInterface::class)->getMockForAbstractClass());
$this->assertSame($voter, $sut->getDecoratedVoter());
}
public function testVote()
{
$voter = $this->getMockBuilder(VoterInterface::class)->getMockForAbstractClass();
$eventDispatcher = $this->getMockBuilder(EventDispatcherInterface::class)->getMockForAbstractClass();
$token = $this->getMockBuilder(TokenInterface::class)->getMockForAbstractClass();
$voter
->expects($this->once())
->method('vote')
->with($token, 'anysubject', array('attr1'))
->willReturn(VoterInterface::ACCESS_DENIED);
$eventDispatcher
->expects($this->once())
->method('dispatch')
->with('debug.security.authorization.vote', new VoteEvent($voter, 'anysubject', array('attr1'), VoterInterface::ACCESS_DENIED));
$sut = new TraceableVoter($voter, $eventDispatcher);
$result = $sut->vote($token, 'anysubject', array('attr1'));
$this->assertSame(VoterInterface::ACCESS_DENIED, $result);
}
}