From 8160138562f8e40ef13f0092a110c8919a861a72 Mon Sep 17 00:00:00 2001 From: Thomas Ploch Date: Thu, 3 Dec 2015 15:54:38 +0100 Subject: [PATCH] [Security] Make it possible to give voters a weight in consensus decisions After watching the "Dig in Security with Symfony" talk at SymfonyCon today, I realized that there was no weighting mechanism in the current implementation. It is not always true that each voter has the equal weight within a majority vote. This feature PR accounts for that in a BC way. | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | BC breaks? | no | Deprecations? | no | Tests pass? | yes | Fixed tickets | None | License | MIT | Doc PR | None Commit recap from rebase: - Introduced the `WeightedVoterInterface` that extends the `VoterInterface` - Added the abstract `WeightedVoter` as an easily usable base class - Added the `Weight` decorator to extend existing Voters with weighting - Refactored test structures for `Voter` - Added missing methods in `Weight` decorator required by `VoterInterface` - Added test case for `Weight` decorator - Added test case for abstract `WeightedVoter` - Added feature tests to `AccessDecisionManagerTest` - Implemented feature in `AccessDecisionManager` - Fixed CodingStandards --- .../Authorization/AccessDecisionManager.php | 28 ++++++- .../Authorization/Voter/Decorator/Weight.php | 78 +++++++++++++++++++ .../Authorization/Voter/WeightedVoter.php | 25 ++++++ .../Voter/WeightedVoterInterface.php | 29 +++++++ .../AccessDecisionManagerTest.php | 75 ++++++++++++++++++ .../Authorization/Voter/BaseVoterTest.php | 60 ++++++++++++++ .../Voter/Decorator/WeightTest.php | 41 ++++++++++ .../Tests/Authorization/Voter/VoterTest.php | 39 +--------- .../Authorization/Voter/WeightedVoterTest.php | 57 ++++++++++++++ 9 files changed, 394 insertions(+), 38 deletions(-) create mode 100644 src/Symfony/Component/Security/Core/Authorization/Voter/Decorator/Weight.php create mode 100644 src/Symfony/Component/Security/Core/Authorization/Voter/WeightedVoter.php create mode 100644 src/Symfony/Component/Security/Core/Authorization/Voter/WeightedVoterInterface.php create mode 100644 src/Symfony/Component/Security/Core/Tests/Authorization/Voter/BaseVoterTest.php create mode 100644 src/Symfony/Component/Security/Core/Tests/Authorization/Voter/Decorator/WeightTest.php create mode 100644 src/Symfony/Component/Security/Core/Tests/Authorization/Voter/WeightedVoterTest.php diff --git a/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php b/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php index 7cefef134f0c5..5298e961d4f24 100644 --- a/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php +++ b/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php @@ -13,6 +13,8 @@ use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\WeightedVoterInterface; +use Symfony\Component\Security\Core\Exception\RuntimeException; /** * AccessDecisionManager is the base class for all access decision managers @@ -155,16 +157,17 @@ private function decideConsensus(TokenInterface $token, array $attributes, $obje $grant = 0; $deny = 0; foreach ($this->voters as $voter) { + $weight = $this->getWeightForVoter($voter); $result = $voter->vote($token, $object, $attributes); switch ($result) { case VoterInterface::ACCESS_GRANTED: - ++$grant; + $grant += $weight; break; case VoterInterface::ACCESS_DENIED: - ++$deny; + $deny += $weight; break; } @@ -220,4 +223,25 @@ private function decideUnanimous(TokenInterface $token, array $attributes, $obje return $this->allowIfAllAbstainDecisions; } + + /** + * @param VoterInterface $voter + * + * @return int + * + * @throws RuntimeException + */ + private function getWeightForVoter(VoterInterface $voter) + { + $weight = 1; + if ($voter instanceof WeightedVoterInterface) { + $weight = (int) $voter->getWeight(); + } + + if ($weight < 1) { + throw new RuntimeException(sprintf('Weighted voter of class "%s" needs to have an integer weight >= 1', get_class($voter))); + } + + return $weight; + } } diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/Decorator/Weight.php b/src/Symfony/Component/Security/Core/Authorization/Voter/Decorator/Weight.php new file mode 100644 index 0000000000000..d7407b3333ae5 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/Decorator/Weight.php @@ -0,0 +1,78 @@ + + * + * 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\Decorator; + +use InvalidArgumentException; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; +use Symfony\Component\Security\Core\Authorization\Voter\WeightedVoterInterface; + +/** + * A decorator to decorate existing voters with the weighted feature. + * + * @author Thomas Ploch + */ +final class Weight implements WeightedVoterInterface +{ + private $voter; + private $weight; + + /** + * Weight constructor. + * + * @param VoterInterface $voter + * @param int $weight + * + * @throws InvalidArgumentException + */ + public function __construct(VoterInterface $voter, $weight) + { + $this->voter = $voter; + $this->weight = (int) $weight; + + if ($this->weight < 1) { + throw new InvalidArgumentException(sprintf('Weight decorator for class "%s" needs to have an integer weight >= 1', get_class($this->voter))); + } + } + + /** + * {@inheritdoc} + */ + public function vote(TokenInterface $token, $subject, array $attributes) + { + return $this->voter->vote($token, $subject, $attributes); + } + + /** + * {@inheritdoc} + */ + public function getWeight() + { + return $this->weight; + } + + /** + * {@inheritdoc} + */ + public function supportsAttribute($attribute) + { + return $this->voter->supportsAttribute($attribute); + } + + /** + * {@inheritdoc} + */ + public function supportsClass($class) + { + return $this->voter->supportsClass($class); + } +} diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/WeightedVoter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/WeightedVoter.php new file mode 100644 index 0000000000000..c3edfebc0a610 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/WeightedVoter.php @@ -0,0 +1,25 @@ + + * + * 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; + +/** + * WeightedVoter is an abstract implementation of a weighted voter. + * + * @author Thomas Ploch + */ +abstract class WeightedVoter extends Voter implements WeightedVoterInterface +{ + /** + * {@inheritdoc} + */ + abstract public function getWeight(); +} diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/WeightedVoterInterface.php b/src/Symfony/Component/Security/Core/Authorization/Voter/WeightedVoterInterface.php new file mode 100644 index 0000000000000..a039800774524 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/WeightedVoterInterface.php @@ -0,0 +1,29 @@ + + * + * 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; + +/** + * WeightedVoterInterface is the interface implemented by voters that have a higher weight in decisions. + * + * @author Thomas Ploch + */ +interface WeightedVoterInterface extends VoterInterface +{ + /** + * This method provides the weight used to come to a weighted authorization decision. + * + * The weight has to be an integer value >= 1. + * + * @return int + */ + public function getWeight(); +} diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php index 412af91c4f516..9d5f85b5d842b 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php @@ -71,6 +71,17 @@ public function testStrategies($strategy, $voters, $allowIfAllAbstainDecisions, $this->assertSame($expected, $manager->decide($token, array('ROLE_FOO'))); } + /** + * @expectedException \Symfony\Component\Security\Core\Exception\RuntimeException + */ + public function testInvalidWeight() + { + $token = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\TokenInterface'); + $voter = $this->getWeightedVoter(VoterInterface::ACCESS_GRANTED, 0); + $manager = new AccessDecisionManager(array($voter), 'consensus'); + $manager->decide($token, array('ROLE_FOO')); + } + /** * @dataProvider getStrategiesWith2RolesTests */ @@ -138,6 +149,30 @@ public function getStrategyTests() array(AccessDecisionManager::STRATEGY_CONSENSUS, $this->getVoters(2, 2, 0), false, false, false), array(AccessDecisionManager::STRATEGY_CONSENSUS, $this->getVoters(2, 2, 1), false, false, false), + // weighted consensus + array(AccessDecisionManager::STRATEGY_CONSENSUS, $this->getWeightedVoters(array(3), array(1,1), array()), false, true, true), + array(AccessDecisionManager::STRATEGY_CONSENSUS, $this->getWeightedVoters(array(3), array(1,1), array(), true), false, true, true), + array(AccessDecisionManager::STRATEGY_CONSENSUS, $this->getWeightedVoters(array(3), array(1,1), array(4)), false, true, true), + array(AccessDecisionManager::STRATEGY_CONSENSUS, $this->getWeightedVoters(array(3), array(1,1), array(4), true), false, true, true), + + array(AccessDecisionManager::STRATEGY_CONSENSUS, $this->getWeightedVoters(array(3), array(1,4), array()), false, true, false), + array(AccessDecisionManager::STRATEGY_CONSENSUS, $this->getWeightedVoters(array(3), array(1,4), array(), true), false, true, false), + array(AccessDecisionManager::STRATEGY_CONSENSUS, $this->getWeightedVoters(array(3), array(1,4), array(6)), false, true, false), + array(AccessDecisionManager::STRATEGY_CONSENSUS, $this->getWeightedVoters(array(3), array(1,4), array(6), true), false, true, false), + + array(AccessDecisionManager::STRATEGY_CONSENSUS, $this->getWeightedVoters(array(3), array(1,2), array()), false, false, false), + array(AccessDecisionManager::STRATEGY_CONSENSUS, $this->getWeightedVoters(array(3), array(1,2), array(), true), false, false, false), + array(AccessDecisionManager::STRATEGY_CONSENSUS, $this->getWeightedVoters(array(3), array(1,2), array(4)), false, false, false), + array(AccessDecisionManager::STRATEGY_CONSENSUS, $this->getWeightedVoters(array(3), array(1,2), array(4), true), false, false, false), + + array(AccessDecisionManager::STRATEGY_CONSENSUS, $this->getWeightedVoters(array(3), array(1,2), array()), false, true, true), + array(AccessDecisionManager::STRATEGY_CONSENSUS, $this->getWeightedVoters(array(3), array(1,2), array(), true), false, true, true), + array(AccessDecisionManager::STRATEGY_CONSENSUS, $this->getWeightedVoters(array(3), array(1,2), array(4)), false, true, true), + array(AccessDecisionManager::STRATEGY_CONSENSUS, $this->getWeightedVoters(array(3), array(1,2), array(4), true), false, true, true), + + array(AccessDecisionManager::STRATEGY_CONSENSUS, $this->getWeightedVoters(array(), array(), array(1,2,4)), true, false, true), + array(AccessDecisionManager::STRATEGY_CONSENSUS, $this->getWeightedVoters(array(), array(), array(1,2,4)), false, false, false), + // unanimous array(AccessDecisionManager::STRATEGY_UNANIMOUS, $this->getVoters(1, 0, 0), false, true, true), array(AccessDecisionManager::STRATEGY_UNANIMOUS, $this->getVoters(1, 0, 1), false, true, true), @@ -164,6 +199,32 @@ protected function getVoters($grants, $denies, $abstains) return $voters; } + protected function getWeightedVoters($grantWeights, $denyWeights, $abstainWeights, $mixInDefaultVoters = false) + { + $voters = array(); + $grants = count($grantWeights); + $denies = count($denyWeights); + $abstains = count($abstainWeights); + + for ($i = 0; $i < $grants; ++$i) { + $voters[] = $this->getWeightedVoter(VoterInterface::ACCESS_GRANTED, $grantWeights[$i]); + } + for ($i = 0; $i < $denies; ++$i) { + $voters[] = $this->getWeightedVoter(VoterInterface::ACCESS_DENIED, $denyWeights[$i]); + } + for ($i = 0; $i < $abstains; ++$i) { + $voters[] = $this->getWeightedVoter(VoterInterface::ACCESS_ABSTAIN, $abstainWeights[$i]); + } + + if (true === $mixInDefaultVoters) { + $voters[] = $this->getVoter(VoterInterface::ACCESS_GRANTED); + $voters[] = $this->getVoter(VoterInterface::ACCESS_DENIED); + $voters[] = $this->getVoter(VoterInterface::ACCESS_ABSTAIN); + } + + return $voters; + } + protected function getVoter($vote) { $voter = $this->getMock('Symfony\Component\Security\Core\Authorization\Voter\VoterInterface'); @@ -174,6 +235,20 @@ protected function getVoter($vote) return $voter; } + protected function getWeightedVoter($vote, $weight) + { + $voter = $this->getMock('Symfony\Component\Security\Core\Authorization\Voter\WeightedVoterInterface'); + $voter->expects($this->any()) + ->method('vote') + ->will($this->returnValue($vote)); + + $voter->expects($this->any()) + ->method('getWeight') + ->will($this->returnValue($weight)); + + return $voter; + } + protected function getVoterSupportsClass($ret) { $voter = $this->getMock('Symfony\Component\Security\Core\Authorization\Voter\VoterInterface'); diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/BaseVoterTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/BaseVoterTest.php new file mode 100644 index 0000000000000..447477c10e2fb --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/BaseVoterTest.php @@ -0,0 +1,60 @@ + + * + * 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 Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; + +abstract class BaseVoterTest extends \PHPUnit_Framework_TestCase +{ + protected $token; + + protected function setUp() + { + $this->token = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\TokenInterface'); + } + + public function getTests() + { + return array( + array(array('EDIT'), VoterInterface::ACCESS_GRANTED, new \stdClass(), 'ACCESS_GRANTED if attribute and class are supported and attribute grants access'), + array(array('CREATE'), VoterInterface::ACCESS_DENIED, new \stdClass(), 'ACCESS_DENIED if attribute and class are supported and attribute does not grant access'), + + array(array('DELETE', 'EDIT'), VoterInterface::ACCESS_GRANTED, new \stdClass(), 'ACCESS_GRANTED if one attribute is supported and grants access'), + array(array('DELETE', 'CREATE'), VoterInterface::ACCESS_DENIED, new \stdClass(), 'ACCESS_DENIED if one attribute is supported and denies access'), + + array(array('CREATE', 'EDIT'), VoterInterface::ACCESS_GRANTED, new \stdClass(), 'ACCESS_GRANTED if one attribute grants access'), + + array(array('DELETE'), VoterInterface::ACCESS_ABSTAIN, new \stdClass(), 'ACCESS_ABSTAIN if no attribute is supported'), + + array(array('EDIT'), VoterInterface::ACCESS_ABSTAIN, $this, 'ACCESS_ABSTAIN if class is not supported'), + + array(array('EDIT'), VoterInterface::ACCESS_ABSTAIN, null, 'ACCESS_ABSTAIN if object is null'), + + array(array(), VoterInterface::ACCESS_ABSTAIN, new \stdClass(), 'ACCESS_ABSTAIN if no attributes were provided'), + ); + } + + /** + * @dataProvider getTests + */ + public function testVote(array $attributes, $expectedVote, $object, $message) + { + $voter = $this->getVoter(); + + $this->assertEquals($expectedVote, $voter->vote($this->token, $object, $attributes), $message); + } + + /** + * @return VoterInterface + */ + abstract protected function getVoter(); +} diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/Decorator/WeightTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/Decorator/WeightTest.php new file mode 100644 index 0000000000000..92350d29d126e --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/Decorator/WeightTest.php @@ -0,0 +1,41 @@ + + * + * 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\Decorator; + +use Symfony\Component\Security\Core\Authorization\Voter\Decorator\Weight; +use Symfony\Component\Security\Core\Tests\Authorization\Voter\BaseVoterTest; +use Symfony\Component\Security\Core\Tests\Authorization\Voter\VoterTest_Voter; + +class WeightTest extends BaseVoterTest +{ + public function testInterface() + { + $voter = $this->getVoter(); + + $this->assertInstanceOf('\Symfony\Component\Security\Core\Authorization\Voter\WeightedVoterInterface', $voter); + $this->assertInstanceOf('\Symfony\Component\Security\Core\Authorization\Voter\VoterInterface', $voter); + } + + public function testWeight() + { + $voter = $this->getVoter(); + + $this->assertEquals(3, $voter->getWeight()); + } + + protected function getVoter() + { + $baseVoter = new VoterTest_Voter(); + + return new Weight($baseVoter, 3); + } +} 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 4bac44d981893..07ed42c1b49d7 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoterTest.php @@ -13,47 +13,14 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Voter; -use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; -class VoterTest extends \PHPUnit_Framework_TestCase +class VoterTest extends BaseVoterTest { - protected $token; - - protected function setUp() + protected function getVoter() { - $this->token = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\TokenInterface'); + return new VoterTest_Voter(); } - public function getTests() - { - return array( - array(array('EDIT'), VoterInterface::ACCESS_GRANTED, new \stdClass(), 'ACCESS_GRANTED if attribute and class are supported and attribute grants access'), - array(array('CREATE'), VoterInterface::ACCESS_DENIED, new \stdClass(), 'ACCESS_DENIED if attribute and class are supported and attribute does not grant access'), - - array(array('DELETE', 'EDIT'), VoterInterface::ACCESS_GRANTED, new \stdClass(), 'ACCESS_GRANTED if one attribute is supported and grants access'), - array(array('DELETE', 'CREATE'), VoterInterface::ACCESS_DENIED, new \stdClass(), 'ACCESS_DENIED if one attribute is supported and denies access'), - - array(array('CREATE', 'EDIT'), VoterInterface::ACCESS_GRANTED, new \stdClass(), 'ACCESS_GRANTED if one attribute grants access'), - - array(array('DELETE'), VoterInterface::ACCESS_ABSTAIN, new \stdClass(), 'ACCESS_ABSTAIN if no attribute is supported'), - - array(array('EDIT'), VoterInterface::ACCESS_ABSTAIN, $this, 'ACCESS_ABSTAIN if class is not supported'), - - array(array('EDIT'), VoterInterface::ACCESS_ABSTAIN, null, 'ACCESS_ABSTAIN if object is null'), - - array(array(), VoterInterface::ACCESS_ABSTAIN, new \stdClass(), 'ACCESS_ABSTAIN if no attributes were provided'), - ); - } - - /** - * @dataProvider getTests - */ - public function testVote(array $attributes, $expectedVote, $object, $message) - { - $voter = new VoterTest_Voter(); - - $this->assertEquals($expectedVote, $voter->vote($this->token, $object, $attributes), $message); - } } class VoterTest_Voter extends Voter diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/WeightedVoterTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/WeightedVoterTest.php new file mode 100644 index 0000000000000..6a01300f99040 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/WeightedVoterTest.php @@ -0,0 +1,57 @@ + + * + * 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 Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\WeightedVoter; + +class WeightedVoterTest extends BaseVoterTest +{ + public function testInterface() + { + $voter = $this->getVoter(); + + $this->assertInstanceOf('\Symfony\Component\Security\Core\Authorization\Voter\WeightedVoterInterface', $voter); + $this->assertInstanceOf('\Symfony\Component\Security\Core\Authorization\Voter\VoterInterface', $voter); + } + + public function testWeight() + { + $voter = $this->getVoter(); + + $this->assertEquals(4, $voter->getWeight()); + } + + protected function getVoter() + { + return new WeightedVoterTest_Voter(); + } + +} + +class WeightedVoterTest_Voter extends WeightedVoter +{ + protected function voteOnAttribute($attribute, $object, TokenInterface $token) + { + return 'EDIT' === $attribute; + } + + protected function supports($attribute, $object) + { + return $object instanceof \stdClass && in_array($attribute, array('EDIT', 'CREATE')); + } + + public function getWeight() + { + return 4; + } +}