Skip to content

Commit ab6c611

Browse files
feature #59150 [Security] Allow using a callable with #[IsGranted] (alexandre-daubois)
This PR was merged into the 7.3 branch. Discussion ---------- [Security] Allow using a callable with `#[IsGranted]` | Q | A | ------------- | --- | Branch? | 7.3 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | - | License | MIT Thanks to [the latest RFC](https://wiki.php.net/rfc/closures_in_const_expr) that successfully passed for the next version of PHP, closures are now allowed in attributes. Symfony could leverage this at multiple places, especially where the ExpressionLanguage is currently used. What's nice is that, compared to expressions, closures can benefit from typing, autocomplete, etc. Way better for the DX. This PR propose to leverage this new feature in `#[IsGranted]`, which would allow to write such code: ```php use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\Security\Core\Authorization\IsGrantedPayload; use Symfony\Component\Security\Http\Attribute\IsGranted; #[AsController] class BlogPostViewController { #[IsGranted(static function ($token, $subject, $accessDecisionManager, $trustResolver): bool { if ($subject->isPublished()) { // published, everybody can see it return false; } if ($subject->getAuthor() === $token->getUser() || $accessDecisionManager->decide($token, 'edit', $subject) ) { // the author can see it return true; } // or any admin of the app return $accessDecisionManager->decide($token, ['ROLE_ADMIN']); }, subject: static function (array $arguments, Request $request) { return $arguments['post']; })] public function __invoke(BlogPost $post): JsonResponse { return new JsonResponse(); } } ``` Commits ------- 9c31038 [Security] Allow using a callable with `#[IsGranted]`
2 parents 3ef4f4e + 9c31038 commit ab6c611

File tree

11 files changed

+691
-9
lines changed

11 files changed

+691
-9
lines changed

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

+8
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\ClosureVoter;
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,13 @@
171172
])
172173
->tag('security.voter', ['priority' => 245])
173174

175+
->set('security.access.closure_voter', ClosureVoter::class)
176+
->args([
177+
service('security.access.decision_manager'),
178+
service('security.authentication.trust_resolver'),
179+
])
180+
->tag('security.voter', ['priority' => 245])
181+
174182
->set('security.impersonate_url_generator', ImpersonateUrlGenerator::class)
175183
->args([
176184
service('request_stack'),

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

+1-1
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; strings, Expression and Closure instances are supported by the core)
2525
* @param AccessDecision|null $accessDecision Should be used to explain the decision
2626
*/
2727
public function isGranted(mixed $attribute, mixed $subject = null/* , ?AccessDecision $accessDecision = null */): bool;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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\AccessDecisionManagerInterface;
17+
use Symfony\Component\Security\Http\Attribute\IsGranted;
18+
19+
/**
20+
* This voter allows using a closure as the attribute being voted on.
21+
*
22+
* The following named arguments are passed to the closure:
23+
*
24+
* - `token`: The token being used for voting
25+
* - `subject`: The subject of the vote
26+
* - `accessDecisionManager`: The access decision manager
27+
* - `trustResolver`: The trust resolver
28+
*
29+
* @see IsGranted doc for the complete closure signature.
30+
*
31+
* @author Alexandre Daubois <alex.daubois@gmail.com>
32+
*/
33+
final class ClosureVoter implements CacheableVoterInterface
34+
{
35+
public function __construct(
36+
private AccessDecisionManagerInterface $accessDecisionManager,
37+
private AuthenticationTrustResolverInterface $trustResolver,
38+
) {
39+
}
40+
41+
public function supportsAttribute(string $attribute): bool
42+
{
43+
return false;
44+
}
45+
46+
public function supportsType(string $subjectType): bool
47+
{
48+
return true;
49+
}
50+
51+
public function vote(TokenInterface $token, mixed $subject, array $attributes, ?Vote $vote = null): int
52+
{
53+
$vote ??= new Vote();
54+
$failingClosures = [];
55+
$result = VoterInterface::ACCESS_ABSTAIN;
56+
foreach ($attributes as $attribute) {
57+
if (!$attribute instanceof \Closure) {
58+
continue;
59+
}
60+
61+
$name = (new \ReflectionFunction($attribute))->name;
62+
$result = VoterInterface::ACCESS_DENIED;
63+
if ($attribute(token: $token, subject: $subject, accessDecisionManager: $this->accessDecisionManager, trustResolver: $this->trustResolver)) {
64+
$vote->reasons[] = \sprintf('Closure %s returned true.', $name);
65+
66+
return VoterInterface::ACCESS_GRANTED;
67+
}
68+
69+
$failingClosures[] = $name;
70+
}
71+
72+
if ($failingClosures) {
73+
$vote->reasons[] = \sprintf('Closure%s %s returned false.', 1 < \count($failingClosures) ? 's' : '', implode(', ', $failingClosures));
74+
}
75+
76+
return $result;
77+
}
78+
}

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

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ CHANGELOG
1010
* Deprecate `UserInterface::eraseCredentials()` and `TokenInterface::eraseCredentials()`,
1111
erase credentials e.g. using `__serialize()` instead
1212
* Add ability for voters to explain their vote
13+
* Add support for voting on closures
1314

1415
7.2
1516
---
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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\AccessDecisionManagerInterface;
18+
use Symfony\Component\Security\Core\Authorization\Voter\ClosureVoter;
19+
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
20+
use Symfony\Component\Security\Core\User\UserInterface;
21+
22+
class ClosureVoterTest extends TestCase
23+
{
24+
private ClosureVoter $voter;
25+
26+
protected function setUp(): void
27+
{
28+
$this->voter = new ClosureVoter(
29+
$this->createMock(AccessDecisionManagerInterface::class),
30+
$this->createMock(AuthenticationTrustResolverInterface::class),
31+
);
32+
}
33+
34+
public function testEmptyAttributeAbstains()
35+
{
36+
$this->assertSame(VoterInterface::ACCESS_ABSTAIN, $this->voter->vote(
37+
$this->createMock(TokenInterface::class),
38+
null,
39+
[])
40+
);
41+
}
42+
43+
public function testClosureReturningFalseDeniesAccess()
44+
{
45+
$token = $this->createMock(TokenInterface::class);
46+
$token->method('getRoleNames')->willReturn([]);
47+
$token->method('getUser')->willReturn($this->createMock(UserInterface::class));
48+
49+
$this->assertSame(VoterInterface::ACCESS_DENIED, $this->voter->vote(
50+
$token,
51+
null,
52+
[fn (...$vars) => 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+
$this->assertSame(VoterInterface::ACCESS_GRANTED, $this->voter->vote(
63+
$token,
64+
null,
65+
[fn (...$vars) => true]
66+
));
67+
}
68+
69+
public function testArgumentsContent()
70+
{
71+
$token = $this->createMock(TokenInterface::class);
72+
$token->method('getRoleNames')->willReturn(['MY_ROLE', 'ANOTHER_ROLE']);
73+
$token->method('getUser')->willReturn($this->createMock(UserInterface::class));
74+
75+
$outerSubject = new \stdClass();
76+
77+
$this->voter->vote(
78+
$token,
79+
$outerSubject,
80+
[function (...$vars) use ($outerSubject) {
81+
$this->assertInstanceOf(TokenInterface::class, $vars['token']);
82+
$this->assertSame($outerSubject, $vars['subject']);
83+
84+
$this->assertInstanceOf(AccessDecisionManagerInterface::class, $vars['accessDecisionManager']);
85+
$this->assertInstanceOf(AuthenticationTrustResolverInterface::class, $vars['trustResolver']);
86+
87+
return true;
88+
}]
89+
);
90+
}
91+
}

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

+11-7
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
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\Authentication\AuthenticationTrustResolverInterface;
17+
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
18+
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
1519

1620
/**
1721
* Checks if user has permission to access to some resource using security roles and voters.
@@ -24,15 +28,15 @@
2428
final class IsGranted
2529
{
2630
/**
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
31+
* @param string|Expression|(\Closure(TokenInterface $token, mixed $subject, AccessDecisionManagerInterface $accessDecisionManager, AuthenticationTrustResolverInterface $trustResolver): bool) $attribute The attribute that will be checked against a given authentication token and optional subject
32+
* @param array|string|Expression|(\Closure(array<string, mixed>, Request): mixed)|null $subject An optional subject - e.g. the current object being voted on
33+
* @param string|null $message A custom message when access is not granted
34+
* @param int|null $statusCode If set, will throw HttpKernel's HttpException with the given $statusCode; if null, Security\Core's AccessDeniedException will be used
35+
* @param int|null $exceptionCode If set, will add the exception code to thrown exception
3236
*/
3337
public function __construct(
34-
public string|Expression $attribute,
35-
public array|string|Expression|null $subject = null,
38+
public string|Expression|\Closure $attribute,
39+
public array|string|Expression|\Closure|null $subject = null,
3640
public ?string $message = null,
3741
public ?int $statusCode = null,
3842
public ?int $exceptionCode = null,

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

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ CHANGELOG
88
* Replace `$hideAccountStatusExceptions` argument with `$exposeSecurityErrors` in `AuthenticatorManager` constructor
99
* Add argument `$identifierNormalizer` to `UserBadge::__construct()` to allow normalizing the identifier
1010
* Support hashing the hashed password using crc32c when putting the user in the session
11+
* Add support for closures in `#[IsGranted]`
1112

1213
7.2
1314
---

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

+3-1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo
5555
foreach ($subjectRef as $refKey => $ref) {
5656
$subject[\is_string($refKey) ? $refKey : (string) $ref] = $this->getIsGrantedSubject($ref, $request, $arguments);
5757
}
58+
} elseif ($subjectRef instanceof \Closure) {
59+
$subject = $subjectRef($arguments, $request);
5860
} else {
5961
$subject = $this->getIsGrantedSubject($subjectRef, $request, $arguments);
6062
}
@@ -69,7 +71,7 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo
6971
}
7072

7173
$e = new AccessDeniedException($message, code: $attribute->exceptionCode ?? 403);
72-
$e->setAttributes($attribute->attribute);
74+
$e->setAttributes([$attribute->attribute]);
7375
$e->setSubject($subject);
7476
$e->setAccessDecision($accessDecision);
7577

0 commit comments

Comments
 (0)