From ab3b12d6dc2da8793414cedcdd7a87652085a95a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 8 Mar 2017 19:39:12 +0100 Subject: [PATCH] [FrameworkBundle][Workflow] Add a way to register a guard expression in the configuration --- .../DependencyInjection/Configuration.php | 5 + .../FrameworkExtension.php | 24 ++++ .../Resources/config/workflow.xml | 2 + .../EventListener/ExpressionLanguage.php | 33 ++++++ .../Workflow/EventListener/GuardListener.php | 79 +++++++++++++ .../Tests/EventListener/GuardListenerTest.php | 105 ++++++++++++++++++ src/Symfony/Component/Workflow/composer.json | 4 +- 7 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 src/Symfony/Component/Workflow/EventListener/ExpressionLanguage.php create mode 100644 src/Symfony/Component/Workflow/EventListener/GuardListener.php create mode 100644 src/Symfony/Component/Workflow/Tests/EventListener/GuardListenerTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index ac4f18da0280c..ae2250e3d44c0 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -336,6 +336,11 @@ private function addWorkflowSection(ArrayNodeDefinition $rootNode) ->isRequired() ->cannotBeEmpty() ->end() + ->scalarNode('guard') + ->cannotBeEmpty() + ->info('An expression to block the transition') + ->example('is_fully_authenticated() and has_role(\'ROLE_JOURNALIST\') and subject.getTitle() == \'My first article\'') + ->end() ->arrayNode('from') ->beforeNormalization() ->ifString() diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 51fc87faa664d..8204a818c0601 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -498,6 +498,30 @@ private function registerWorkflowConfiguration(array $workflows, ContainerBuilde $listener->addArgument(new Reference('logger')); $container->setDefinition(sprintf('%s.listener.audit_trail', $workflowId), $listener); } + + // Add Guard Listener + $guard = new Definition(Workflow\EventListener\GuardListener::class); + $configuration = array(); + foreach ($workflow['transitions'] as $transitionName => $config) { + if (!isset($config['guard'])) { + continue; + } + $eventName = sprintf('workflow.%s.guard.%s', $name, $transitionName); + $guard->addTag('kernel.event_listener', array('event' => $eventName, 'method' => 'onTransition')); + $configuration[$eventName] = $config['guard']; + } + if ($configuration) { + $guard->setArguments(array( + $configuration, + new Reference('workflow.security.expression_language'), + new Reference('security.token_storage'), + new Reference('security.authorization_checker'), + new Reference('security.authentication.trust_resolver'), + new Reference('security.role_hierarchy'), + )); + + $container->setDefinition(sprintf('%s.listener.guard', $workflowId), $guard); + } } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/workflow.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/workflow.xml index 76592087a2260..7bfd2f7b00bab 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/workflow.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/workflow.xml @@ -27,5 +27,7 @@ + + diff --git a/src/Symfony/Component/Workflow/EventListener/ExpressionLanguage.php b/src/Symfony/Component/Workflow/EventListener/ExpressionLanguage.php new file mode 100644 index 0000000000000..46d5d7520c0fb --- /dev/null +++ b/src/Symfony/Component/Workflow/EventListener/ExpressionLanguage.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\EventListener; + +use Symfony\Component\Security\Core\Authorization\ExpressionLanguage as BaseExpressionLanguage; + +/** + * Adds some function to the default Symfony Security ExpressionLanguage. + * + * @author Fabien Potencier + */ +class ExpressionLanguage extends BaseExpressionLanguage +{ + protected function registerFunctions() + { + parent::registerFunctions(); + + $this->register('is_granted', function ($attributes, $object = 'null') { + return sprintf('$auth_checker->isGranted(%s, %s)', $attributes, $object); + }, function (array $variables, $attributes, $object = null) { + return $variables['auth_checker']->isGranted($attributes, $object); + }); + } +} diff --git a/src/Symfony/Component/Workflow/EventListener/GuardListener.php b/src/Symfony/Component/Workflow/EventListener/GuardListener.php new file mode 100644 index 0000000000000..20ba04c007fc2 --- /dev/null +++ b/src/Symfony/Component/Workflow/EventListener/GuardListener.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\EventListener; + +use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; +use Symfony\Component\Workflow\Event\GuardEvent; + +/** + * @author Grégoire Pineau + */ +class GuardListener +{ + private $configuration; + private $expressionLanguage; + private $tokenStorage; + private $authenticationChecker; + private $trustResolver; + private $roleHierarchy; + + public function __construct($configuration, ExpressionLanguage $expressionLanguage, TokenStorageInterface $tokenStorage, AuthorizationCheckerInterface $authenticationChecker, AuthenticationTrustResolverInterface $trustResolver, RoleHierarchyInterface $roleHierarchy = null) + { + $this->configuration = $configuration; + $this->expressionLanguage = $expressionLanguage; + $this->tokenStorage = $tokenStorage; + $this->authenticationChecker = $authenticationChecker; + $this->trustResolver = $trustResolver; + $this->roleHierarchy = $roleHierarchy; + } + + public function onTransition(GuardEvent $event, $eventName) + { + if (!isset($this->configuration[$eventName])) { + return; + } + + if (!$this->expressionLanguage->evaluate($this->configuration[$eventName], $this->getVariables($event))) { + $event->setBlocked(true); + } + } + + // code should be sync with Symfony\Component\Security\Core\Authorization\Voter\ExpressionVoter + private function getVariables(GuardEvent $event) + { + $token = $this->tokenStorage->getToken(); + + if (null !== $this->roleHierarchy) { + $roles = $this->roleHierarchy->getReachableRoles($token->getRoles()); + } else { + $roles = $token->getRoles(); + } + + $variables = array( + 'token' => $token, + 'user' => $token->getUser(), + 'subject' => $event->getSubject(), + 'roles' => array_map(function ($role) { + return $role->getRole(); + }, $roles), + // needed for the is_granted expression function + 'auth_checker' => $this->authenticationChecker, + // needed for the is_* expression function + 'trust_resolver' => $this->trustResolver, + ); + + return $variables; + } +} diff --git a/src/Symfony/Component/Workflow/Tests/EventListener/GuardListenerTest.php b/src/Symfony/Component/Workflow/Tests/EventListener/GuardListenerTest.php new file mode 100644 index 0000000000000..b46ee9092c573 --- /dev/null +++ b/src/Symfony/Component/Workflow/Tests/EventListener/GuardListenerTest.php @@ -0,0 +1,105 @@ + 'true', + 'event_name_b' => 'false', + ); + + $expressionLanguage = new ExpressionLanguage(); + $this->tokenStorage = $this->getMockBuilder(TokenStorageInterface::class)->getMock(); + $authenticationChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock(); + $trustResolver = $this->getMockBuilder(AuthenticationTrustResolverInterface::class)->getMock(); + + $this->listener = new GuardListener($configuration, $expressionLanguage, $this->tokenStorage, $authenticationChecker, $trustResolver); + } + + protected function tearDown() + { + $this->listener = null; + } + + public function testWithNotSupportedEvent() + { + $event = $this->createEvent(); + $this->configureTokenStorage(false); + + $this->listener->onTransition($event, 'not supported'); + + $this->assertFalse($event->isBlocked()); + } + + public function testWithSupportedEventAndReject() + { + $event = $this->createEvent(); + $this->configureTokenStorage(true); + + $this->listener->onTransition($event, 'event_name_a'); + + $this->assertFalse($event->isBlocked()); + } + + public function testWithSupportedEventAndAccept() + { + $event = $this->createEvent(); + $this->configureTokenStorage(true); + + $this->listener->onTransition($event, 'event_name_b'); + + $this->assertTrue($event->isBlocked()); + } + + private function createEvent() + { + $subject = new \stdClass(); + $subject->marking = new Marking(); + $transition = new Transition('name', 'from', 'to'); + + return new GuardEvent($subject, $subject->marking, $transition); + } + + private function configureTokenStorage($hasUser) + { + if (!$hasUser) { + $this->tokenStorage + ->expects($this->never()) + ->method('getToken') + ; + + return; + } + + $token = $this->getMockBuilder(TokenInterface::class)->getMock(); + $token + ->expects($this->once()) + ->method('getRoles') + ->willReturn(array(new Role('ROLE_ADMIN'))) + ; + + $this->tokenStorage + ->expects($this->once()) + ->method('getToken') + ->willReturn($token) + ; + } +} diff --git a/src/Symfony/Component/Workflow/composer.json b/src/Symfony/Component/Workflow/composer.json index f0447d7a9b211..66f8f65dc041f 100644 --- a/src/Symfony/Component/Workflow/composer.json +++ b/src/Symfony/Component/Workflow/composer.json @@ -25,7 +25,9 @@ }, "require-dev": { "psr/log": "~1.0", - "symfony/event-dispatcher": "~2.1|~3.0" + "symfony/event-dispatcher": "~2.1|~3.0", + "symfony/expression-language": "~2.8|~3.0", + "symfony/security-core": "~2.8|~3.0" }, "autoload": { "psr-4": { "Symfony\\Component\\Workflow\\": "" }