Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/twig-hooks/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/TwigHooks/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/TwigHooks/config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
20 changes: 18 additions & 2 deletions src/TwigHooks/src/Hookable/Checker/HookableConditionChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}

Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]));
}
Expand All @@ -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');
Expand Down
Loading