Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit f5cee77

Browse files
committed
[Security] Allow using expressions with the #[IsGranted] attribute
1 parent 445f0f1 commit f5cee77

File tree

9 files changed

+172
-29
lines changed

9 files changed

+172
-29
lines changed

src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/AddExpressionLanguageProvidersPass.php

+1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ public function process(ContainerBuilder $container)
3636

3737
if (!$container->hasDefinition('cache.system')) {
3838
$container->removeDefinition('cache.security_expression_language');
39+
$container->removeDefinition('cache.security_is_granted_attribute_expression_language');
3940
}
4041
}
4142
}

src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php

+1
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ public function load(array $configs, ContainerBuilder $container)
112112
if (!$container::willBeAvailable('symfony/expression-language', ExpressionLanguage::class, ['symfony/security-bundle'])) {
113113
$container->removeDefinition('security.expression_language');
114114
$container->removeDefinition('security.access.expression_voter');
115+
$container->removeDefinition('security.is_granted_attribute_expression_language');
115116
}
116117

117118
// set some global scalars

src/Symfony/Bundle/SecurityBundle/Resources/config/security.php

+12-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Symfony\Bundle\SecurityBundle\Security\FirewallMap;
1919
use Symfony\Bundle\SecurityBundle\Security\LazyFirewallContext;
2020
use Symfony\Bundle\SecurityBundle\Security\Security;
21+
use Symfony\Component\ExpressionLanguage\ExpressionLanguage as BaseExpressionLanguage;
2122
use Symfony\Component\Ldap\Security\LdapUserProvider;
2223
use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolver;
2324
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
@@ -275,7 +276,17 @@
275276
->tag('kernel.cache_warmer')
276277

277278
->set('controller.is_granted_attribute_listener', IsGrantedAttributeListener::class)
278-
->args([service('security.authorization_checker')])
279+
->args([
280+
service('security.authorization_checker'),
281+
service('security.is_granted_attribute_expression_language')->nullOnInvalid(),
282+
])
279283
->tag('kernel.event_subscriber')
284+
285+
->set('security.is_granted_attribute_expression_language', BaseExpressionLanguage::class)
286+
->args([service('cache.security_is_granted_attribute_expression_language')->nullOnInvalid()])
287+
288+
->set('cache.security_is_granted_attribute_expression_language')
289+
->parent('cache.system')
290+
->tag('cache.pool')
280291
;
281292
};

src/Symfony/Component/Security/Http/Attribute/IsGranted.php

+6-2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
namespace Symfony\Component\Security\Http\Attribute;
1313

14+
use Symfony\Component\ExpressionLanguage\Expression;
15+
1416
/**
1517
* @author Ryan Weaver <[email protected]>
1618
*/
@@ -21,12 +23,14 @@ public function __construct(
2123
/**
2224
* Sets the first argument that will be passed to isGranted().
2325
*/
24-
public string $attribute,
26+
public string|Expression $attribute,
2527

2628
/**
2729
* Sets the second argument passed to isGranted().
30+
*
31+
* @var array<string|Expression>|string|Expression|null
2832
*/
29-
public array|string|null $subject = null,
33+
public array|string|Expression|null $subject = null,
3034

3135
/**
3236
* The message of the exception - has a nice default if not set.

src/Symfony/Component/Security/Http/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ CHANGELOG
99
* Deprecate empty username or password when using when using `JsonLoginAuthenticator`
1010
* Set custom lifetime for login link
1111
* Add `$lifetime` parameter to `LoginLinkHandlerInterface::createLoginLink()`
12+
* Allow using expressions as `#[IsGranted()]` attribute and subject
1213

1314
6.0
1415
---

src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php

+33-14
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
namespace Symfony\Component\Security\Http\EventListener;
1313

1414
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
15+
use Symfony\Component\ExpressionLanguage\Expression;
16+
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
1517
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
1618
use Symfony\Component\HttpKernel\Exception\HttpException;
1719
use Symfony\Component\HttpKernel\KernelEvents;
@@ -28,7 +30,8 @@
2830
class IsGrantedAttributeListener implements EventSubscriberInterface
2931
{
3032
public function __construct(
31-
private AuthorizationCheckerInterface $authChecker,
33+
private readonly AuthorizationCheckerInterface $authChecker,
34+
private ?ExpressionLanguage $expressionLanguage = null,
3235
) {
3336
}
3437

@@ -42,21 +45,15 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event)
4245
$arguments = $event->getNamedArguments();
4346

4447
foreach ($attributes as $attribute) {
45-
$subjectRef = $attribute->subject;
4648
$subject = null;
4749

48-
if ($subjectRef) {
50+
if ($subjectRef = $attribute->subject) {
4951
if (\is_array($subjectRef)) {
50-
foreach ($subjectRef as $ref) {
51-
if (!\array_key_exists($ref, $arguments)) {
52-
throw new RuntimeException(sprintf('Could not find the subject "%s" for the #[IsGranted] attribute. Try adding a "$%s" argument to your controller method.', $ref, $ref));
53-
}
54-
$subject[$ref] = $arguments[$ref];
52+
foreach ($subjectRef as $refKey => $ref) {
53+
$subject[\is_string($refKey) ? $refKey : (string) $ref] = $this->getIsGrantedSubject($ref, $arguments);
5554
}
56-
} elseif (!\array_key_exists($subjectRef, $arguments)) {
57-
throw new RuntimeException(sprintf('Could not find the subject "%s" for the #[IsGranted] attribute. Try adding a "$%s" argument to your controller method.', $subjectRef, $subjectRef));
5855
} else {
59-
$subject = $arguments[$subjectRef];
56+
$subject = $this->getIsGrantedSubject($subjectRef, $arguments);
6057
}
6158
}
6259

@@ -81,15 +78,37 @@ public static function getSubscribedEvents(): array
8178
return [KernelEvents::CONTROLLER_ARGUMENTS => ['onKernelControllerArguments', 10]];
8279
}
8380

81+
private function getIsGrantedSubject(string|Expression $subjectRef, array $arguments): mixed
82+
{
83+
if ($subjectRef instanceof Expression) {
84+
$this->expressionLanguage ??= new ExpressionLanguage();
85+
86+
return $this->expressionLanguage->evaluate($subjectRef, [
87+
'args' => $arguments,
88+
]);
89+
}
90+
91+
if (!\array_key_exists($subjectRef, $arguments)) {
92+
throw new RuntimeException(sprintf('Could not find the subject "%s" for the #[IsGranted] attribute. Try adding a "$%s" argument to your controller method.', $subjectRef, $subjectRef));
93+
}
94+
95+
return $arguments[$subjectRef];
96+
}
97+
8498
private function getIsGrantedString(IsGranted $isGranted): string
8599
{
86-
$processValue = fn ($value) => sprintf('"%s"', $value);
100+
$processValue = fn ($value) => sprintf($value instanceof Expression ? 'new Expression("%s")' : '"%s"', $value);
87101

88102
$argsString = $processValue($isGranted->attribute);
89103

90104
if (null !== $subject = $isGranted->subject) {
91-
$subject = array_map($processValue, (array) $subject);
92-
$argsString .= ', '.(1 === \count($subject) ? reset($subject) : '['.implode(', ', $subject).']');
105+
$subject = !\is_array($subject) ? $processValue($subject) : array_map(function ($key, $value) use ($processValue) {
106+
$value = $processValue($value);
107+
108+
return \is_string($key) ? sprintf('"%s" => %s', $key, $value) : $value;
109+
}, array_keys($subject), $subject);
110+
111+
$argsString .= ', '.(!\is_array($subject) ? $subject : '['.implode(', ', $subject).']');
93112
}
94113

95114
return $argsString;

src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeListenerTest.php

+97-11
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
namespace Symfony\Component\Security\Http\Tests\EventListener;
1313

1414
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\ExpressionLanguage\Expression;
1516
use Symfony\Component\HttpFoundation\Request;
1617
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
1718
use Symfony\Component\HttpKernel\Exception\HttpException;
1819
use Symfony\Component\HttpKernel\HttpKernelInterface;
1920
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
21+
use Symfony\Component\Security\Core\Authorization\ExpressionLanguage;
2022
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
2123
use Symfony\Component\Security\Http\EventListener\IsGrantedAttributeListener;
2224
use Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeController;
@@ -42,7 +44,7 @@ public function testAttribute()
4244
$listener = new IsGrantedAttributeListener($authChecker);
4345
$listener->onKernelControllerArguments($event);
4446

45-
$authChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock();
47+
$authChecker = $this->createMock(AuthorizationCheckerInterface::class);
4648
$authChecker->expects($this->once())
4749
->method('isGranted')
4850
->willReturn(true);
@@ -61,7 +63,7 @@ public function testAttribute()
6163

6264
public function testNothingHappensWithNoConfig()
6365
{
64-
$authChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock();
66+
$authChecker = $this->createMock(AuthorizationCheckerInterface::class);
6567
$authChecker->expects($this->never())
6668
->method('isGranted');
6769

@@ -79,7 +81,7 @@ public function testNothingHappensWithNoConfig()
7981

8082
public function testIsGrantedCalledCorrectly()
8183
{
82-
$authChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock();
84+
$authChecker = $this->createMock(AuthorizationCheckerInterface::class);
8385
$authChecker->expects($this->once())
8486
->method('isGranted')
8587
->with('ROLE_ADMIN')
@@ -99,7 +101,7 @@ public function testIsGrantedCalledCorrectly()
99101

100102
public function testIsGrantedSubjectFromArguments()
101103
{
102-
$authChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock();
104+
$authChecker = $this->createMock(AuthorizationCheckerInterface::class);
103105
$authChecker->expects($this->once())
104106
->method('isGranted')
105107
// the subject => arg2name will eventually resolve to the 2nd argument, which has this value
@@ -146,7 +148,7 @@ public function testIsGrantedSubjectFromArgumentsWithArray()
146148

147149
public function testIsGrantedNullSubjectFromArguments()
148150
{
149-
$authChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock();
151+
$authChecker = $this->createMock(AuthorizationCheckerInterface::class);
150152
$authChecker->expects($this->once())
151153
->method('isGranted')
152154
->with('ROLE_ADMIN', null)
@@ -166,7 +168,7 @@ public function testIsGrantedNullSubjectFromArguments()
166168

167169
public function testIsGrantedArrayWithNullValueSubjectFromArguments()
168170
{
169-
$authChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock();
171+
$authChecker = $this->createMock(AuthorizationCheckerInterface::class);
170172
$authChecker->expects($this->once())
171173
->method('isGranted')
172174
->with('ROLE_ADMIN', [
@@ -191,7 +193,7 @@ public function testExceptionWhenMissingSubjectAttribute()
191193
{
192194
$this->expectException(\RuntimeException::class);
193195

194-
$authChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock();
196+
$authChecker = $this->createMock(AuthorizationCheckerInterface::class);
195197

196198
$event = new ControllerArgumentsEvent(
197199
$this->createMock(HttpKernelInterface::class),
@@ -208,17 +210,22 @@ public function testExceptionWhenMissingSubjectAttribute()
208210
/**
209211
* @dataProvider getAccessDeniedMessageTests
210212
*/
211-
public function testAccessDeniedMessages(string $attribute, string|array|null $subject, string $method, int $numOfArguments, string $expectedMessage)
213+
public function testAccessDeniedMessages(string|Expression $attribute, string|array|null $subject, string $method, int $numOfArguments, string $expectedMessage)
212214
{
213-
$authChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock();
215+
$authChecker = $this->createMock(AuthorizationCheckerInterface::class);
214216
$authChecker->expects($this->any())
215217
->method('isGranted')
216218
->willReturn(false);
217219

220+
$expressionLanguage = $this->createMock(ExpressionLanguage::class);
221+
$expressionLanguage->expects($this->any())
222+
->method('evaluate')
223+
->willReturn('bar');
224+
218225
// avoid the error of the subject not being found in the request attributes
219226
$arguments = array_fill(0, $numOfArguments, 'bar');
220227

221-
$listener = new IsGrantedAttributeListener($authChecker);
228+
$listener = new IsGrantedAttributeListener($authChecker, $expressionLanguage);
222229

223230
$event = new ControllerArgumentsEvent(
224231
$this->createMock(HttpKernelInterface::class),
@@ -233,7 +240,7 @@ public function testAccessDeniedMessages(string $attribute, string|array|null $s
233240
$this->fail();
234241
} catch (AccessDeniedException $e) {
235242
$this->assertSame($expectedMessage, $e->getMessage());
236-
$this->assertSame([$attribute], $e->getAttributes());
243+
$this->assertEquals([$attribute], $e->getAttributes());
237244
if (null !== $subject) {
238245
$this->assertSame($subject, $e->getSubject());
239246
} else {
@@ -247,6 +254,9 @@ public function getAccessDeniedMessageTests()
247254
yield ['ROLE_ADMIN', null, 'admin', 0, 'Access Denied by #[IsGranted("ROLE_ADMIN")] on controller'];
248255
yield ['ROLE_ADMIN', 'bar', 'withSubject', 2, 'Access Denied by #[IsGranted("ROLE_ADMIN", "arg2Name")] on controller'];
249256
yield ['ROLE_ADMIN', ['arg1Name' => 'bar', 'arg2Name' => 'bar'], 'withSubjectArray', 2, 'Access Denied by #[IsGranted("ROLE_ADMIN", ["arg1Name", "arg2Name"])] on controller'];
257+
yield [new Expression('"ROLE_ADMIN" in role_names or is_granted("POST_VIEW", subject)'), 'bar', 'withExpressionInAttribute', 1, 'Access Denied by #[IsGranted(new Expression(""ROLE_ADMIN" in role_names or is_granted("POST_VIEW", subject)"), "post")] on controller'];
258+
yield [new Expression('user === subject'), 'bar', 'withExpressionInSubject', 1, 'Access Denied by #[IsGranted(new Expression("user === subject"), new Expression("args["post"].getAuthor()"))] on controller'];
259+
yield [new Expression('user === subject["author"]'), ['author' => 'bar', 'alias' => 'bar'], 'withNestedExpressionInSubject', 2, 'Access Denied by #[IsGranted(new Expression("user === subject["author"]"), ["author" => new Expression("args["post"].getAuthor()"), "alias" => "arg2Name"])] on controller'];
250260
}
251261

252262
public function testNotFoundHttpException()
@@ -270,4 +280,80 @@ public function testNotFoundHttpException()
270280
$listener = new IsGrantedAttributeListener($authChecker);
271281
$listener->onKernelControllerArguments($event);
272282
}
283+
284+
public function testIsGrantedwithExpressionInAttribute()
285+
{
286+
$authChecker = $this->createMock(AuthorizationCheckerInterface::class);
287+
$authChecker->expects($this->once())
288+
->method('isGranted')
289+
->with(new Expression('"ROLE_ADMIN" in role_names or is_granted("POST_VIEW", subject)'), 'postVal')
290+
->willReturn(true);
291+
292+
$event = new ControllerArgumentsEvent(
293+
$this->createMock(HttpKernelInterface::class),
294+
[new IsGrantedAttributeMethodsController(), 'withExpressionInAttribute'],
295+
['postVal'],
296+
new Request(),
297+
null
298+
);
299+
300+
$listener = new IsGrantedAttributeListener($authChecker);
301+
$listener->onKernelControllerArguments($event);
302+
}
303+
304+
public function testIsGrantedwithExpressionInSubject()
305+
{
306+
$authChecker = $this->createMock(AuthorizationCheckerInterface::class);
307+
$authChecker->expects($this->once())
308+
->method('isGranted')
309+
->with(new Expression('user === subject'), 'author')
310+
->willReturn(true);
311+
312+
$expressionLanguage = $this->createMock(ExpressionLanguage::class);
313+
$expressionLanguage->expects($this->once())
314+
->method('evaluate')
315+
->with(new Expression('args["post"].getAuthor()'), [
316+
'args' => ['post' => 'postVal'],
317+
])
318+
->willReturn('author');
319+
320+
$event = new ControllerArgumentsEvent(
321+
$this->createMock(HttpKernelInterface::class),
322+
[new IsGrantedAttributeMethodsController(), 'withExpressionInSubject'],
323+
['postVal'],
324+
new Request(),
325+
null
326+
);
327+
328+
$listener = new IsGrantedAttributeListener($authChecker, $expressionLanguage);
329+
$listener->onKernelControllerArguments($event);
330+
}
331+
332+
public function testIsGrantedwithNestedExpressionInSubject()
333+
{
334+
$authChecker = $this->createMock(AuthorizationCheckerInterface::class);
335+
$authChecker->expects($this->once())
336+
->method('isGranted')
337+
->with(new Expression('user === subject["author"]'), ['author' => 'author', 'alias' => 'arg2Val'])
338+
->willReturn(true);
339+
340+
$expressionLanguage = $this->createMock(ExpressionLanguage::class);
341+
$expressionLanguage->expects($this->once())
342+
->method('evaluate')
343+
->with(new Expression('args["post"].getAuthor()'), [
344+
'args' => ['post' => 'postVal', 'arg2Name' => 'arg2Val'],
345+
])
346+
->willReturn('author');
347+
348+
$event = new ControllerArgumentsEvent(
349+
$this->createMock(HttpKernelInterface::class),
350+
[new IsGrantedAttributeMethodsController(), 'withNestedExpressionInSubject'],
351+
['postVal', 'arg2Val'],
352+
new Request(),
353+
null
354+
);
355+
356+
$listener = new IsGrantedAttributeListener($authChecker, $expressionLanguage);
357+
$listener->onKernelControllerArguments($event);
358+
}
273359
}

src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeMethodsController.php

+19
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\Component\Security\Http\Tests\Fixtures;
1313

14+
use Symfony\Component\ExpressionLanguage\Expression;
1415
use Symfony\Component\Security\Http\Attribute\IsGranted;
1516

1617
class IsGrantedAttributeMethodsController
@@ -43,4 +44,22 @@ public function withMissingSubject()
4344
public function notFound()
4445
{
4546
}
47+
48+
#[IsGranted(attribute: new Expression('"ROLE_ADMIN" in role_names or is_granted("POST_VIEW", subject)'), subject: 'post')]
49+
public function withExpressionInAttribute($post)
50+
{
51+
}
52+
53+
#[IsGranted(attribute: new Expression('user === subject'), subject: new Expression('args["post"].getAuthor()'))]
54+
public function withExpressionInSubject($post)
55+
{
56+
}
57+
58+
#[IsGranted(attribute: new Expression('user === subject["author"]'), subject: [
59+
'author' => new Expression('args["post"].getAuthor()'),
60+
'alias' => 'arg2Name',
61+
])]
62+
public function withNestedExpressionInSubject($post, $arg2Name)
63+
{
64+
}
4665
}

0 commit comments

Comments
 (0)