8000 [DX][Security] Allow using a callable with `#[IsGranted]` · symfony/symfony@da421f7 · GitHub
[go: up one dir, main page]

Skip to content

Commit da421f7

Browse files
[DX][Security] Allow using a callable with #[IsGranted]
1 parent 8c841e3 commit da421f7

File tree

12 files changed

+789
-16
lines changed

12 files changed

+789
-16
lines changed

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
use Symfony\Component\Security\Core\Authorization\UserAuthorizationChecker;
3535
use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface;
3636
use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter;
37+
use Symfony\Component\Security\Core\Authorization\Voter\CallableVoter;
3738
use Symfony\Component\Security\Core\Authorization\Voter\ExpressionVoter;
3839
use Symfony\Component\Security\Core\Authorization\Voter\RoleHierarchyVoter;
3940
use Symfony\Component\Security\Core\Authorization\Voter\RoleVoter;
@@ -171,6 +172,14 @@
171172
])
172173
->tag('security.voter', ['priority' => 245])
173174

175+
->set('security.access.callable_voter', CallableVoter::class)
176+
->args([
177+
service('security.authentication.trust_resolver'),
178+
service('security.authorization_checker'),
179+
service('security.role_hierarchy')->nullOnInvalid(),
180+
])
181+
->tag('security.voter', ['priority' => 245])
182+
174183
->set('security.impersonate_url_generator', ImpersonateUrlGenerator::class)
175184
->args([
176185
service('request_stack'),

src/Symfony/Component/Security/Core/Authorization/AuthorizationCheckerInterface.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ interface AuthorizationCheckerInterface
2121
/**
2222
* Checks if the attribute is granted against the current authentication token and optionally supplied subject.
2323
*
24-
* @param mixed $attribute A single attribute to vote on (can be of any type, string and instance of Expression are supported by the core)
24+
* @param mixed $attribute A single attribute to vote on (can be of any type, string, instance of Expression and Closures are supported by the core)
2525
*/
2626
public function isGranted(mixed $attribute, mixed $subject = null): bool;
2727
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Core\Authorization\Voter;
13+
14+
use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface;
15+
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
16+
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
17+
18+
/**
19+
* @author Alexandre Daubois <alex.daubois@gmail.com>
20+
*/
21+
final class CallableVoter implements CacheableVoterInterface
22+
{
23+
public function __construct(
24+
private AuthenticationTrustResolverInterface $trustResolver,
25+
private AuthorizationCheckerInterface $authChecker,
26+
) {
27+
}
28+
29+
public function supportsAttribute(string $attribute): bool
30+
{
31+
return false;
32+
}
33+
34+
public function supportsType(string $subjectType): bool
35+
{
36+
return true;
37+
}
38+
39+
public function vote(TokenInterface $token, mixed $subject, array $attributes): int
40+
{
41+
$result = VoterInterface::ACCESS_ABSTAIN;
42+
foreach ($attributes as $attribute) {
43+
if (!$attribute instanceof \Closure) {
44+
continue;
45+
}
46+
47+
$result = VoterInterface::ACCESS_DENIED;
48+
if ($attribute($token, $subject, $this->trustResolver, $this->authChecker)) {
49+
return VoterInterface::ACCESS_GRANTED;
50+
}
51+
}
52+
53+
return $result;
54+
}
55+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ CHANGELOG
77
* Add `UserAuthorizationChecker::userIsGranted()` to test user authorization without relying on the session.
88
For example, users not currently logged in, or while processing a message from a message queue.
99
* Add `OfflineTokenInterface` to mark tokens that do not represent the currently logged-in user
10+
* Add support for voting on callables
1011

1112
7.2
1213
---
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Core\Tests\Authorization\Voter;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface;
16+
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
17+
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
18+
use Symfony\Component\Security\Core\Authorization\Voter\CallableVoter;
19+
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
20+
use Symfony\Component\Security\Core\User\UserInterface;
21+
22+
class CallableVoterTest extends TestCase
23+
{
24+
public function testEmptyAttributeAbstains()
25+
{
26+
$voter = new CallableVoter(
27+
$this->createMock(AuthenticationTrustResolverInterface::class),
28+
$this->createMock(AuthorizationCheckerInterface::class),
29+
);
30+
31+
$this->assertSame(VoterInterface::ACCESS_ABSTAIN, $voter->vote(
32+
$this->createMock(TokenInterface::class),
33+
null,
34+
[])
35+
);
36+
}
37+
38+
public function testClosureReturningFalseDeniesAccess()
39+
{
40+
$token = $this->createMock(TokenInterface::class);
41+
$token->method('getRoleNames')->willReturn([]);
42+
$token->method('getUser')->willReturn($this->createMock(UserInterface::class));
43+
44+
$voter = new CallableVoter(
45+
$this->createMock(AuthenticationTrustResolverInterface::class),
46+
$this->createMock(AuthorizationCheckerInterface::class),
47+
);
48+
49+
$this->assertSame(VoterInterface::ACCESS_DENIED, $voter->vote(
50+
$token,
51+
null,
52+
[fn () => false]
53+
));
54+
}
55+
56+
public function testClosureReturningTrueGrantsAccess()
57+
{
58+
$token = $this->createMock(TokenInterface::class);
59+
$token->method('getRoleNames')->willReturn([]);
60+
$token->method('getUser')->willReturn($this->createMock(UserInterface::class));
61+
62+
$voter = new CallableVoter(
63+
$this->createMock(AuthenticationTrustResolverInterface::class),
64+
$this->createMock(AuthorizationCheckerInterface::class),
65+
);
66+
67+
$this->assertSame(VoterInterface::ACCESS_GRANTED, $voter->vote(
68+
$token,
69+
null,
70+
[fn () => true]
71+
));
72+
}
73+
74+
public function testPayloadContent()
75+
{
76+
$token = $this->createMock(TokenInterface::class);
77+
$token->method('getRoleNames')->willReturn(['MY_ROLE', 'ANOTHER_ROLE']);
78+
$token->method('getUser')->willReturn($this->createMock(UserInterface::class));
79+
80+
$subject = new \stdClass();
81+
82+
$voter = new CallableVoter(
83+
$this->createMock(AuthenticationTrustResolverInterface::class),
84+
$this->createMock(AuthorizationCheckerInterface::class),
85+
);
86+
87+
$voter->vote(
88+
$token,
89+
$subject,
90+
[function ($token, $closureSubject, $trustResolver, $authorizationChecker) use ($subject) {
91+
$this->assertInstanceOf(TokenInterface::class, $token);
92+
$this->assertSame($subject, $closureSubject);
93+
94+
$this->assertInstanceOf(AuthenticationTrustResolverInterface::class, $trustResolver);
95+
$this->assertInstanceOf(AuthorizationCheckerInterface::class, $authorizationChecker);
96+
97+
return true;
98+
}]
99+
);
100+
}
101+
}

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

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
namespace Symfony\Component\Security\Http\Attribute;
1313

1414
use Symfony\Component\ExpressionLanguage\Expression;
15+
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
1517

1618
/**
1719
* Checks if user has permission to access to some resource using security roles and voters.
@@ -24,18 +26,37 @@
2426
final class IsGranted
2527
{
2628
/**
27-
* @param string|Expression $attribute The attribute that will be checked against a given authentication token and optional subject
28-
* @param array|string|Expression|null $subject An optional subject - e.g. the current object being voted on
29-
* @param string|null $message A custom message when access is not granted
30-
* @param int|null $statusCode If set, will throw HttpKernel's HttpException with the given $statusCode; if null, Security\Core's AccessDeniedException will be used
31-
* @param int|null $exceptionCode If set, will add the exception code to thrown exception
29+
* @param string|Expression|null $attribute The attribute that will be checked against a given authentication token and optional subject, or a callable that will be called to determine access
30+
* @param array|string|Expression|\Closure(Request $request, array $arguments): mixed|null $subject An optional subject - e.g. the current object being voted on
31+
* @param string|null $message A custom message when access is not granted
32+
* @param int|null $statusCode If set, will throw HttpKernel's HttpException with the given $statusCode; if null, Security\Core's AccessDeniedException will be used
33+
* @param int|null $exceptionCode If set, will add the exception code to thrown exception
34+
* @param \Closure(AuthorizationCheckerInterface, mixed $subject): bool|null $callable A callable that will be called to determine access
3235
*/
3336
public function __construct(
34-
public string|Expression $attribute,
35-
public array|string|Expression|null $subject = null,
37+
public string|Expression|null $attribute = null,
38+
public array|string|Expression|\Closure|null $subject = null,
3639
public ?string $message = null,
3740
public ?int $statusCode = null,
3841
public ?int $exceptionCode = null,
42+
public ?\Closure $callable = null,
3943
) {
44+
if (\PHP_VERSION_ID < 80500) {
45+
if (null === $attribute) {
46+
throw new \LogicException('The "attribute" argument is only optional starting from PHP 8.5.');
47+
}
48+
49+
if (null !== $callable) {
50+
throw new \LogicException('The "callable" argument is usable only starting from PHP 8.5.');
51+
}
52+
53+
if ($subject instanceof \Closure) {
54+
throw new \LogicException('The "subject" argument can be a callable only starting from PHP 8.5.');
55+
}
56+
} else {
57+
if (!(null === $attribute xor null === $callable)) {
58+
throw new \LogicException('Either the "attribute" or "callable" argument must be set.');
59+
}
60+
}
4061
}
4162
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.3
5+
---
6+
7+
* Add support for closures in `#[IsGranted]`
8+
49
7.2
510
---
611

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

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,20 +54,26 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo
5454
foreach ($subjectRef as $refKey => $ref) {
5555
$subject[\is_string($refKey) ? $refKey : (string) $ref] = $this->getIsGrantedSubject($ref, $request, $arguments);
5656
}
57+
} elseif ($subjectRef instanceof \Closure) {
58+
$subject = $subjectRef($request, $arguments);
5759
} else {
5860
$subject = $this->getIsGrantedSubject($subjectRef, $request, $arguments);
5961
}
6062
}
6163

62-
if (!$this->authChecker->isGranted($attribute->attribute, $subject)) {
64+
if (!$this->authChecker->isGranted($attribute->callable ?? $attribute->attribute, $subject)) {
6365
$message = $attribute->message ?: \sprintf('Access Denied by #[IsGranted(%s)] on controller', $this->getIsGrantedString($attribute));
6466

6567
if ($statusCode = $attribute->statusCode) {
6668
throw new HttpException($statusCode, $message, code: $attribute->exceptionCode ?? 0);
6769
}
6870

6971
$accessDeniedException = new AccessDeniedException($message, code: $attribute->exceptionCode ?? 403);
70-
$accessDeniedException->setAttributes($attribute->attribute);
72+
73+
if ($attribute->attribute) {
74+
$accessDeniedException->setAttributes($attribute->attribute);
75+
}
76+
7177
$accessDeniedException->setSubject($subject);
7278

7379
throw $accessDeniedException;
@@ -102,14 +108,18 @@ private function getIsGrantedString(IsGranted $isGranted): string
102108
{
103109
$processValue = fn ($value) => \sprintf($value instanceof Expression ? 'new Expression("%s")' : '"%s"', $value);
104110

105-
$argsString = $processValue($isGranted->attribute);
111+
$argsString = $isGranted->callable ? 'callable' : $processValue($isGranted->attribute);
106112

107113
if (null !== $subject = $isGranted->subject) {
108-
$subject = !\is_array($subject) ? $processValue($subject) : array_map(function ($key, $value) use ($processValue) {
109-
$value = $processValue($value);
110-
111-
return \is_string($key) ? \sprintf('"%s" => %s', $key, $value) : $value;
112-
}, array_keys($subject), $subject);
114+
if (\is_callable($subject)) {
115+
$subject = 'callable';
116+
} else {
117+
$subject = !\is_array($subject) ? $processValue($subject) : array_map(function ($key, $value) use ($processValue) {
118+
$value = $processValue($value);
119+
120+
return \is_string($key) ? \sprintf('"%s" => %s', $key, $value) : $value;
121+
}, array_keys($subject), $subject);
122+
}
113123

114124
$argsString .= ', '.(!\is_array($subject) ? $subject : '['.implode(', ', $subject).']');
115125
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Http\Tests\Attribute;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Security\Http\Attribute\IsGranted;
16+
17+
class IsGrantedTest extends TestCase
18+
{
19+
/**
20+
* @requires PHP < 8.5
21+
*/
22+
public function testNullAttributePriorToPhp85()
23+
{
24+
$this->expectException(\LogicException::class);
25+
$this->expectExceptionMessage('The "attribute" argument is only optional starting from PHP 8.5.');
26+
27+
new IsGranted();
28+
}
29+
30+
/**
31+
* @requires PHP < 8.5
32+
*/
33+
public function testNotNullCallablePriorToPhp85()
34+
{
35+
$this->expectException(\LogicException::class);
36+
$this->expectExceptionMessage('The "callable" argument is usable only starting from PHP 8.5.');
37+
38+
new IsGranted('attribute', callable: static function () {});
39+
}
40+
41+
/**
42+
* @requires PHP < 8.5
43+
*/
44+
public function testCallableSubjectPriorToPhp85()
45+
{
46+
$this->expectException(\LogicException::class);
47+
$this->expectExceptionMessage('The "subject" argument can be a callable only starting from PHP 8.5.');
48+
49+
new IsGranted('attribute', subject: static function () {});
50+
}
51+
52+
/**
53+
* @requires PHP 8.5
54+
*/
55+
public function testAttributeAndCallableAreNull()
56+
{
57+
$this->expectException(\LogicException::class);
58+
$this->expectExceptionMessage('Either the "attribute" or "callable" argument must be set.');
59+
60+
new IsGranted();
61+
}
62+
63+
/**
64+
* @requires PHP 8.5
65+
*/
66+
public function testAttributeAndCallableAreNotNull()
67+
{
68+
$this->expectException(\LogicException::class);
69+
$this->expectExceptionMessage('Either the "attribute" or "callable" argument must be set.');
70+
71+
new IsGranted('attribute', callable: static function () {});
72+
}
73+
}

0 commit comments

Comments
 (0)
0