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

Skip to content

Commit b493c5d

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

12 files changed

+881
-16
lines changed

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,39 @@
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;
13+
14+
use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface;
15+
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
16+
use Symfony\Component\Security\Core\User\UserInterface;
17+
18+
/**
19+
* @template T
20+
*
21+
* @author Alexandre Daubois <alex.daubois@gmail.com>
22+
*/
23+
final class IsGrantedPayload
24+
{
25+
public UserInterface $user;
26+
27+
/**
28+
* @param T $subject
29+
*/
30+
public function __construct(
31+
public TokenInterface $token,
32+
public mixed $subject,
33+
public array $roleNames,
34+
public AuthenticationTrustResolverInterface $trustResolver,
35+
public AuthorizationCheckerInterface $authorizationChecker,
36+
) {
37+
$this->user = $token->getUser();
38+
}
39+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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\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\AuthorizationCheckerInterface;
19+
use Symfony\Component\Security\Core\Authorization\IsGrantedPayload;
20+
use Symfony\Component\Security\Core\Authorization\ExpressionLanguage;
21+
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
22+
23+
/**
24+
* @author Alexandre Daubois <alex.daubois@gmail.com>
25+
*/
26+
final class CallableVoter implements CacheableVoterInterface
27+
{
28+
public function __construct(
29+
private AuthenticationTrustResolverInterface $trustResolver,
30+
private AuthorizationCheckerInterface $authChecker,
31+
private ?RoleHierarchyInterface $roleHierarchy = null,
32+
) {
33+
}
34+
35+
public function supportsAttribute(string $attribute): bool
36+
{
37+
return false;
38+
}
39+
40+
public function supportsType(string $subjectType): bool
41+
{
42+
return true;
43+
}
44+
45+
public function vote(TokenInterface $token, mixed $subject, array $attributes): int
46+
{
47+
$result = VoterInterface::ACCESS_ABSTAIN;
48+
$payload = null;
49+
foreach ($attributes as $attribute) {
50+
if (!\is_callable($attribute)) {
51+
continue;
52+
}
53+
54+
$payload ??= $this->getPayload($token, $subject);
55+
56+
$result = VoterInterface::ACCESS_DENIED;
57+
if ($attribute($payload)) {
58+
return VoterInterface::ACCESS_GRANTED;
59+
}
60+
}
61+
62+
return $result;
63+
}
64+
65+
private function getPayload(TokenInterface $token, mixed $subject): IsGrantedPayload
66+
{
67+
$roleNames = $token->getRoleNames();
68+
69+
if (null !== $this->roleHierarchy) {
70+
$roleNames = $this->roleHierarchy->getReachableRoleNames($roleNames);
71+
}
72+
73+
return new IsGrantedPayload(
74+
$token,
75+
$subject,
76+
$roleNames,
77+
$this->trustResolver,
78+
$this->authChecker
79+
);
80+
}
81+
}

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 closures
1011

1112
7.2
1213
---
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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\IsGrantedPayload;
19+
use Symfony\Component\Security\Core\Authorization\Voter\CallableVoter;
20+
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
21+
use Symfony\Component\Security\Core\User\UserInterface;
22+
23+
class CallableVoterTest extends TestCase
24+
{
25+
public function testEmptyAttributeAbstains()
26+
{
27+
$voter = new CallableVoter(
28+
$this->createMock(AuthenticationTrustResolverInterface::class),
29+
$this->createMock(AuthorizationCheckerInterface::class),
30+
);
31+
32+
$this->assertSame(VoterInterface::ACCESS_ABSTAIN, $voter->vote(
33+
$this->createMock(TokenInterface::class),
34+
null,
35+
[])
36+
);
37+
}
38+
39+
public function testClosureReturningFalseDeniesAccess()
40+
{
41+
$token = $this->createMock(TokenInterface::class);
42+
$token->method('getRoleNames')->willReturn([]);
43+
$token->method('getUser')->willReturn($this->createMock(UserInterface::class));
44+
45+
$voter = new CallableVoter(
46+
$this->createMock(AuthenticationTrustResolverInterface::class),
47+
$this->createMock(AuthorizationCheckerInterface::class),
48+
);
49+
50+
$this->assertSame(VoterInterface::ACCESS_DENIED, $voter->vote(
51+
$token,
52+
null,
53+
[fn() => false]
54+
));
55+
}
56+
57+
public function testClosureReturningTrueGrantsAccess()
58+
{
59+
$token = $this->createMock(TokenInterface::class);
60+
$token->method('getRoleNames')->willReturn([]);
61+
$token->method('getUser')->willReturn($this->createMock(UserInterface::class));
62+
63+
$voter = new CallableVoter(
64+
$this->createMock(AuthenticationTrustResolverInterface::class),
65+
$this->createMock(AuthorizationCheckerInterface::class),
66+
);
67+
68+
$this->assertSame(VoterInterface::ACCESS_GRANTED, $voter->vote(
69+
$token,
70+
null,
71+
[fn() => true]
72+
));
73+
}
74+
75+
public function testPayloadContent()
76+
{
77+
$token = $this->createMock(TokenInterface::class);
78+
$token->method('getRoleNames')->willReturn(['MY_ROLE', 'ANOTHER_ROLE']);
79+
$token->method('getUser')->willReturn($this->createMock(UserInterface::class));
80+
81+
$subject = new \stdClass();
82+
83+
$voter = new CallableVoter(
84+
$this->createMock(AuthenticationTrustResolverInterface::class),
85+
$this->createMock(AuthorizationCheckerInterface::class),
86+
);
87+
88+
$voter->vote(
89+
$token,
90+
$subject,
91+
[function (IsGrantedPayload $payload) use ($subject) {
92+
$this->assertInstanceOf(TokenInterface::class, $payload->token);
93+
$this->assertSame($subject, $payload->subject);
94+
95+
$this->assertSame(['MY_ROLE', 'ANOTHER_ROLE'], $payload->roleNames);
96+
97+
$this->assertInstanceOf(AuthenticationTrustResolverInterface::class, $payload->trustResolver);
98+
$this->assertInstanceOf(AuthorizationCheckerInterface::class, $payload->authorizationChecker);
99+
100+
return true;
101+
}]
102+
);
103+
}
104+
}

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

+28-7
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|null|\Closure(Request $request, array $arguments): mixed $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 null|\Closure(AuthorizationCheckerInterface, mixed $subject): bool $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|null|\Closure $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 (\is_callable($subject)) {
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

+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-8
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 (\is_callable($subjectRef)) {
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
}

0 commit comments

Comments
 (0)
0