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

Skip to content

Commit 3c1d6cd

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

File tree

11 files changed

+708
-10
lines changed

11 files changed

+708
-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, string, instance of Expression and Closures 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,66 @@
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+
* The following arguments are passed to the closure, in this order:
22+
*
23+
* - The token being used for voting
24+
* - The subject of the vote
25+
* - The access decision manager
26+
* - The trust resolver
27+
*
28+
* @see IsGranted doc for the complete closure signature.
29+
*
30+
* @author Alexandre Daubois <alex.daubois@gmail.com>
31+
*/
32+
final class ClosureVoter implements CacheableVoterInterface
33+
{
34+
public function __construct(
35+
private AccessDecisionManagerInterface $accessDecisionManager,
36+
private AuthenticationTrustResolverInterface $trustResolver,
37+
) {
38+
}
39+
40+
public function supportsAttribute(string $attribute): bool
41+
{
42+
return false;
43+
}
44+
45+
public function supportsType(string $subjectType): bool
46+
{
47+
return true;
48+
}
49+
50+
public function vote(TokenInterface $token, mixed $subject, array $attributes): int
51+
{
52+
$result = VoterInterface::ACCESS_ABSTAIN;
53+
foreach ($attributes as $attribute) {
54+
if (!$attribute instanceof \Closure) {
55+
continue;
56+
}
57+
58+
$result = VoterInterface::ACCESS_DENIED;
59+
if ($attribute($token, $subject, $this->accessDecisionManager, $this->trustResolver)) {
60+
return VoterInterface::ACCESS_GRANTED;
61+
}
62+
}
63+
64+
return $result;
65+
}
66+
}

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

+1
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
---
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(
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 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 () => 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 ClosureVoter(
83+
$this->createMock(AccessDecisionManagerInterface::class),
84+
$this->createMock(AuthenticationTrustResolverInterface::class),
85+
);
86+
87+
$voter->vote(
88+
$token,
89+
$subject,
90+
[function ($token, $closureSubject, $accessDecisionManager, $trustResolver) use ($subject) {
91+
$this->assertInstanceOf(TokenInterface::class, $token);
92+
$this->assertSame($subject, $closureSubject);
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, or a callable that will be called to determine access
32+
* @param array|string|Expression|(\Closure(array<string, mixed> $arguments, Request $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

+5
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

+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 = function ($value) {
110+
if ($value instanceof Expression) {
111+
return \sprintf('new Expression("%s")', $value);
112+
}
113+
114+
if ($value instanceof \Closure) {
115+
return '\Closure';
116+
}
117+
118+
return \sprintf('"%s"', $value);
119+
};
104120

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

0 commit comments

Comments
 (0)
0