From 3aea93ce0c196f613fa8f58f58ba2bf9b8c232e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Fr=C3=A9mont?= Date: Fri, 25 Sep 2026 17:46:44 +0200 Subject: [PATCH 1/2] Add is_granted support to Twig hook conditions --- docs/twig-hooks/getting-started.md | 9 +++++++++ src/TwigHooks/composer.json | 1 + src/TwigHooks/config/services.php | 1 + .../SyliusTwigHooksExtension.php | 1 + .../Hookable/Checker/HookableConditionChecker.php | 13 ++++++++++++- .../Checker/HookableConditionCheckerTest.php | 12 ++++++++++++ 6 files changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/twig-hooks/getting-started.md b/docs/twig-hooks/getting-started.md index e1c965dd..1be1a8f5 100644 --- a/docs/twig-hooks/getting-started.md +++ b/docs/twig-hooks/getting-started.md @@ -130,6 +130,15 @@ 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. +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. {% endhint %} 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..9fe57193 100644 --- a/src/TwigHooks/config/services.php +++ b/src/TwigHooks/config/services.php @@ -58,6 +58,7 @@ $services->set('sylius_twig_hooks.checker.hookable_condition', HookableConditionChecker::class) ->args([ inline_service(ExpressionLanguage::class), + service('security.authorization_checker')->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..fcad68d2 100644 --- a/src/TwigHooks/src/Hookable/Checker/HookableConditionChecker.php +++ b/src/TwigHooks/src/Hookable/Checker/HookableConditionChecker.php @@ -17,11 +17,13 @@ use Sylius\TwigHooks\Hookable\AbstractHookable; use Sylius\TwigHooks\Provider\Exception\InvalidExpressionException; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; final class HookableConditionChecker implements HookableConditionCheckerInterface { public function __construct( private readonly ExpressionLanguage $expressionLanguage, + private readonly ?AuthorizationCheckerInterface $authorizationChecker = null, ) { } @@ -38,8 +40,17 @@ public function isEnabled(AbstractHookable $hookable, array $context): bool $values = array_merge($context, ['_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..61d479ae 100644 --- a/src/TwigHooks/tests/Unit/Hookable/Checker/HookableConditionCheckerTest.php +++ b/src/TwigHooks/tests/Unit/Hookable/Checker/HookableConditionCheckerTest.php @@ -19,6 +19,7 @@ use Sylius\TwigHooks\Hookable\HookableTemplate; use Sylius\TwigHooks\Provider\Exception\InvalidExpressionException; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Tests\Sylius\TwigHooks\Utils\MotherObject\HookableTemplateMotherObject; final class HookableConditionCheckerTest extends TestCase @@ -51,6 +52,17 @@ 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 testItKeepsContextVariableReservedWhenContextContainsCollidingKey(): void { $hookable = new HookableTemplate('some_hook', 'some_name', 'some_target', condition: '@=_context.user !== null'); From 3f5a3dba63ad6515c5d8090b515f51b889874942 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Fr=C3=A9mont?= Date: Fri, 25 Sep 2026 18:02:52 +0200 Subject: [PATCH 2/2] Expose the authenticated user in hook conditions --- docs/twig-hooks/getting-started.md | 2 +- src/TwigHooks/config/services.php | 1 + .../Checker/HookableConditionChecker.php | 7 ++++++- .../Checker/HookableConditionCheckerTest.php | 21 +++++++++++++++++-- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/twig-hooks/getting-started.md b/docs/twig-hooks/getting-started.md index 1be1a8f5..01e22ef4 100644 --- a/docs/twig-hooks/getting-started.md +++ b/docs/twig-hooks/getting-started.md @@ -128,7 +128,7 @@ 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: diff --git a/src/TwigHooks/config/services.php b/src/TwigHooks/config/services.php index 9fe57193..6e53fa70 100644 --- a/src/TwigHooks/config/services.php +++ b/src/TwigHooks/config/services.php @@ -59,6 +59,7 @@ ->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/Hookable/Checker/HookableConditionChecker.php b/src/TwigHooks/src/Hookable/Checker/HookableConditionChecker.php index fcad68d2..8c0f5d77 100644 --- a/src/TwigHooks/src/Hookable/Checker/HookableConditionChecker.php +++ b/src/TwigHooks/src/Hookable/Checker/HookableConditionChecker.php @@ -17,6 +17,7 @@ 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 @@ -24,6 +25,7 @@ final class HookableConditionChecker implements HookableConditionCheckerInterfac public function __construct( private readonly ExpressionLanguage $expressionLanguage, private readonly ?AuthorizationCheckerInterface $authorizationChecker = null, + private readonly ?TokenStorageInterface $tokenStorage = null, ) { } @@ -38,7 +40,10 @@ 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 { diff --git a/src/TwigHooks/tests/Unit/Hookable/Checker/HookableConditionCheckerTest.php b/src/TwigHooks/tests/Unit/Hookable/Checker/HookableConditionCheckerTest.php index 61d479ae..158b2141 100644 --- a/src/TwigHooks/tests/Unit/Hookable/Checker/HookableConditionCheckerTest.php +++ b/src/TwigHooks/tests/Unit/Hookable/Checker/HookableConditionCheckerTest.php @@ -19,7 +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 @@ -33,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])); } @@ -63,6 +66,20 @@ public function testItEvaluatesIsGrantedCondition(): void $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');