8000 [Security] Allow using a callable with `#[IsGranted]` · symfony/security-core@4f59544 · GitHub
[go: up one dir, main page]

Skip to content

Commit 4f59544

Browse files
alexandre-dauboisnicolas-grekas
authored andcommitted
[Security] Allow using a callable with #[IsGranted]
1 parent 5e5c218 commit 4f59544

File tree

4 files changed

+171
-1
lines changed

4 files changed

+171
-1
lines changed

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; 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;

Authorization/Voter/ClosureVoter.php

Lines changed: 78 additions & 0 deletions
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+
}

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
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
---
Lines changed: 91 additions & 0 deletions
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+
}

0 commit comments

Comments
 (0)
0