diff --git a/docs/twig-hooks/getting-started.md b/docs/twig-hooks/getting-started.md index e1c965dd..01e22ef4 100644 --- a/docs/twig-hooks/getting-started.md +++ b/docs/twig-hooks/getting-started.md @@ -128,7 +128,16 @@ condition: '@=user != null' condition: '@=_context.user != null' ``` -Because the expression is evaluated with the whole hook context and the `_context` variable, this behaves like [Twig's `defined`](https://twig.symfony.com/doc/3.x/tests/defined.html)-style checks: `@=_context.product` returns `null` when the variable is absent from the context, so the hookable is simply not rendered. Referencing a missing variable directly (e.g. `@=product ...`) throws a clear `InvalidExpressionException` instead, which makes invalid conditions fail fast during development. +The `user` variable is provided from Symfony's current security token, as in Symfony security expressions. To refer to a `user` value passed explicitly in the hook context, use `_context.user`. For context keys, `_context` behaves like [Twig's `defined`](https://twig.symfony.com/doc/3.x/tests/defined.html)-style check: `@=_context.product` returns `null` when the variable is absent from the context. Referencing a missing variable directly (e.g. `@=product ...`) throws a clear `InvalidExpressionException` instead, which makes invalid conditions fail fast during development. + +Conditions can also use Symfony's `is_granted()` authorization check. Pass the required role or attribute, and optionally a subject: + +```yaml +condition: '@=is_granted("ROLE_ADMIN")' +condition: '@=is_granted("EDIT", _context.product)' +``` + +The check uses Symfony's authorization checker, so voters and access decision rules apply as they do in Twig templates. {% hint style="info" %} On Symfony 7.1 and newer, conditions are validated at the application boot: an invalid expression is reported as soon as the container is compiled. On older versions, errors are only reported at runtime, when the hook is rendered. diff --git a/src/TwigHooks/composer.json b/src/TwigHooks/composer.json index 800ad5c3..b13c9c89 100644 --- a/src/TwigHooks/composer.json +++ b/src/TwigHooks/composer.json @@ -20,6 +20,7 @@ "symfony/dependency-injection": "^6.4 || ^7.4 || ^8.0", "symfony/expression-language": "^6.4 || ^7.4 || ^8.0", "symfony/http-kernel": "^6.4 || ^7.4 || ^8.0", + "symfony/security-core": "^6.4 || ^7.4 || ^8.0", "symfony/stopwatch": "^6.4 || ^7.4 || ^8.0", "symfony/ux-live-component": "^2.17 || ^3.0", "symfony/ux-twig-component": "^2.17 || ^3.0", diff --git a/src/TwigHooks/config/services.php b/src/TwigHooks/config/services.php index a5d723a5..6e53fa70 100644 --- a/src/TwigHooks/config/services.php +++ b/src/TwigHooks/config/services.php @@ -58,6 +58,8 @@ $services->set('sylius_twig_hooks.checker.hookable_condition', HookableConditionChecker::class) ->args([ inline_service(ExpressionLanguage::class), + service('security.authorization_checker')->nullOnInvalid(), + service('security.token_storage')->nullOnInvalid(), ]) ; $services->alias(HookableConditionCheckerInterface::class, 'sylius_twig_hooks.checker.hookable_condition'); diff --git a/src/TwigHooks/src/DependencyInjection/SyliusTwigHooksExtension.php b/src/TwigHooks/src/DependencyInjection/SyliusTwigHooksExtension.php index ec4f8bac..a764e5c1 100644 --- a/src/TwigHooks/src/DependencyInjection/SyliusTwigHooksExtension.php +++ b/src/TwigHooks/src/DependencyInjection/SyliusTwigHooksExtension.php @@ -50,6 +50,7 @@ public function load(array $configs, ContainerBuilder $container): void private function registerHooks(ContainerBuilder $container, array $hooks, array $supportedHookableTypes): void { $expressionLanguage = new ExpressionLanguage(); + $expressionLanguage->register('is_granted', static fn (string ...$arguments): string => sprintf('is_granted(%s)', implode(', ', $arguments)), static fn (): bool => false); foreach ($hooks as $hookName => $hookables) { foreach ($hookables as $hookableName => $hookable) { diff --git a/src/TwigHooks/src/Hookable/Checker/HookableConditionChecker.php b/src/TwigHooks/src/Hookable/Checker/HookableConditionChecker.php index 9a691b79..8c0f5d77 100644 --- a/src/TwigHooks/src/Hookable/Checker/HookableConditionChecker.php +++ b/src/TwigHooks/src/Hookable/Checker/HookableConditionChecker.php @@ -17,11 +17,15 @@ use Sylius\TwigHooks\Hookable\AbstractHookable; use Sylius\TwigHooks\Provider\Exception\InvalidExpressionException; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; final class HookableConditionChecker implements HookableConditionCheckerInterface { public function __construct( private readonly ExpressionLanguage $expressionLanguage, + private readonly ?AuthorizationCheckerInterface $authorizationChecker = null, + private readonly ?TokenStorageInterface $tokenStorage = null, ) { } @@ -36,10 +40,22 @@ public function isEnabled(AbstractHookable $hookable, array $context): bool return true; } - $values = array_merge($context, ['_context' => new DataBag($context)]); + $values = array_merge($context, [ + 'user' => $this->tokenStorage?->getToken()?->getUser(), + '_context' => new DataBag($context), + ]); + + $expressionLanguage = clone $this->expressionLanguage; + $expressionLanguage->register('is_granted', static fn (string ...$arguments): string => sprintf('is_granted(%s)', implode(', ', $arguments)), function (array $variables, string $attribute, mixed $subject = null): bool { + if (null === $this->authorizationChecker) { + return false; + } + + return $this->authorizationChecker->isGranted($attribute, $subject); + }); try { - return (bool) $this->expressionLanguage->evaluate(substr($condition, 2), $values); + return (bool) $expressionLanguage->evaluate(substr($condition, 2), $values); } catch (\Throwable $e) { throw new InvalidExpressionException( sprintf( diff --git a/src/TwigHooks/tests/Unit/Hookable/Checker/HookableConditionCheckerTest.php b/src/TwigHooks/tests/Unit/Hookable/Checker/HookableConditionCheckerTest.php index 2d169af1..158b2141 100644 --- a/src/TwigHooks/tests/Unit/Hookable/Checker/HookableConditionCheckerTest.php +++ b/src/TwigHooks/tests/Unit/Hookable/Checker/HookableConditionCheckerTest.php @@ -19,6 +19,10 @@ use Sylius\TwigHooks\Hookable\HookableTemplate; use Sylius\TwigHooks\Provider\Exception\InvalidExpressionException; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\User\UserInterface; use Tests\Sylius\TwigHooks\Utils\MotherObject\HookableTemplateMotherObject; final class HookableConditionCheckerTest extends TestCase @@ -32,14 +36,14 @@ public function testItReturnsTrueWhenNoConditionIsDefined(): void public function testItReturnsTrueWhenConditionIsSatisfied(): void { - $hookable = new HookableTemplate('some_hook', 'some_name', 'some_target', condition: '@=user !== null'); + $hookable = new HookableTemplate('some_hook', 'some_name', 'some_target', condition: '@=_context.user !== null'); $this->assertTrue($this->createTestSubject()->isEnabled($hookable, ['user' => new \stdClass()])); } public function testItReturnsFalseWhenConditionIsNotSatisfied(): void { - $hookable = new HookableTemplate('some_hook', 'some_name', 'some_target', condition: '@=user !== null'); + $hookable = new HookableTemplate('some_hook', 'some_name', 'some_target', condition: '@=_context.user !== null'); $this->assertFalse($this->createTestSubject()->isEnabled($hookable, ['user' => null])); } @@ -51,6 +55,31 @@ public function testItResolvesConditionAgainstContextVariable(): void $this->assertTrue($this->createTestSubject()->isEnabled($hookable, ['user' => new \stdClass()])); } + public function testItEvaluatesIsGrantedCondition(): void + { + $hookable = new HookableTemplate('some_hook', 'some_name', 'some_target', condition: '@=is_granted("ROLE_ADMIN")'); + $authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authorizationChecker->expects($this->once())->method('isGranted')->with('ROLE_ADMIN', null)->willReturn(true); + + $checker = new HookableConditionChecker(new ExpressionLanguage(), $authorizationChecker); + + $this->assertTrue($checker->isEnabled($hookable, [])); + } + + public function testItResolvesUserFromSecurityToken(): void + { + $hookable = new HookableTemplate('some_hook', 'some_name', 'some_target', condition: '@=user !== null'); + $user = $this->createMock(UserInterface::class); + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn($user); + $tokenStorage = $this->createMock(TokenStorageInterface::class); + $tokenStorage->method('getToken')->willReturn($token); + + $checker = new HookableConditionChecker(new ExpressionLanguage(), tokenStorage: $tokenStorage); + + $this->assertTrue($checker->isEnabled($hookable, [])); + } + public function testItKeepsContextVariableReservedWhenContextContainsCollidingKey(): void { $hookable = new HookableTemplate('some_hook', 'some_name', 'some_target', condition: '@=_context.user !== null');