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

Skip to content

Commit 54712d5

Browse files
[DX][Security] Allow using a callable with #[IsGranted]
1 parent 3cb2479 commit 54712d5

File tree

11 files changed

+705
-10
lines changed

11 files changed

+705
-10
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 Closures instances are supported by the core)
2525
*/
2626
public function isGranted(mixed $attribute, mixed $subject = null): bool;
2727
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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): int
52+
{
53+
$result = VoterInterface::ACCESS_ABSTAIN;
54+
foreach ($attributes as $attribute) {
55+
if (!$attribute instanceof \Closure) {
56+
continue;
57+
}
58+
59+
$result = VoterInterface::ACCESS_DENIED;
60+
if ($attribute(token: $token, subject: $subject, accessDecisionManager: $this->accessDecisionManager, trustResolver: $this->trustResolver)) {
61+
return VoterInterface::ACCESS_GRANTED;
62+
}
63+
}
64+
65+
return $result;
66+
}
67+
}

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

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ CHANGELOG
99
* Add `OfflineTokenInterface` to mark tokens that do not represent the currently logged-in user
1010
* Deprecate `UserInterface::eraseCredentials()` and `TokenInterface::eraseCredentials()`,
1111
erase credentials e.g. using `__serialize()` instead
12+
* Add support for voting on closures
1213

1314
7.2
1415
---
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\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+
public function testEmptyAttributeAbstains()
25+
{
26+
$voter = new ClosureVoter(
27+
$this->createMock(AccessDecisionManagerInterface::class),
28+
$this->createMock(AuthenticationTrustResolverInterface::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 ClosureVoter(
45+
$this->createMock(AccessDecisionManagerInterface::class),
46+
$this->createMock(AuthenticationTrustResolverInterface::class),
47+
);
48+
49+
$this->assertSame(VoterInterface::ACCESS_DENIED, $voter->vote F438 (
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+
$voter = new ClosureVoter(
63+
$this->createMock(AccessDecisionManagerInterface::class),
64+
$this->createMock(AuthenticationTrustResolverInterface::class),
65+
);
66+
67+
$this->assertSame(VoterInterface::ACCESS_GRANTED, $voter->vote(
68+
$token,
69+
null,
70+
[fn (...$vars) => true]
71+
));
72+
}
73+
74+
public function testArgumentsContent()
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+
$outerSubject = new \stdClass();
81+
82+
$voter = new ClosureVoter(
83+
$this->createMock(AccessDecisionManagerInterface::class),
84+
$this->createMock(AuthenticationTrustResolverInterface::class),
85+
);
86+
87+
$voter->vote(
88+
$token,
89+
$outerSubject,
90+
[function ($token, $subject, $accessDecisionManager, $trustResolver) use ($outerSubject) {
91+
$this->assertInstanceOf(TokenInterface::class, $token);
92+
$this->assertSame($outerSubject, $subject);
93+
94+
$this->assertInstanceOf(AccessDecisionManagerInterface::class, $accessDecisionManager);
95+
$this->assertInstanceOf(AuthenticationTrustResolverInterface::class, $trustResolver);
96+
97+
return true;
98+
}]
99+
);
100+
}
101+
}

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

+18-2
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ 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($arguments, $request);
5759
} else {
5860
$subject = $this->getIsGrantedSubject($subjectRef, $request, $arguments);
5961
}
@@ -67,7 +69,11 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo
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;
@@ -100,7 +106,17 @@ private function getIsGrantedSubject(string|Expression $subjectRef, Request $req
100106

101107
private function getIsGrantedString(IsGranted $isGranted): string
102108
{
103-
$processValue = fn ($value) => \sprintf($value instanceof Expression ? 'new Expression("%s")' : '"%s"', $value);
109+
$processValue = static function ($value) {
110+
if ($value instanceof Expression) {
111+
return \sprintf('new Expression("%s")', $value);
112+
}
113+
114+
if ($value instanceof \Closure) {
115+
return (new \ReflectionFunction($value))->name;
116+
}
117+
118+
return \sprintf('"%s"', $value);
119+
};
104120

105121
$argsString = $processValue($isGranted->attribute);
106122

0 commit comments

Comments
 (0)
0