8000 [Security] Allow using a callable with `#[IsGranted]` by alexandre-daubois · Pull Request #59150 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

[Security] Allow using a callable with #[IsGranted] #59150

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
8000
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
use Symfony\Component\Security\Core\Authorization\UserAuthorizationChecker;
use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter;
use Symfony\Component\Security\Core\Authorization\Voter\ClosureVoter;
use Symfony\Component\Security\Core\Authorization\Voter\ExpressionVoter;
use Symfony\Component\Security\Core\Authorization\Voter\RoleHierarchyVoter;
use Symfony\Component\Security\Core\Authorization\Voter\RoleVoter;
Expand Down Expand Up @@ -171,6 +172,13 @@
])
->tag('security.voter', ['priority' => 245])

->set('security.access.closure_voter', ClosureVoter::class)
->args([
service('security.access.decision_manager'),
service('security.authentication.trust_resolver'),
])
->tag('security.voter', ['priority' => 245])

->set('security.impersonate_url_generator', ImpersonateUrlGenerator::class)
->args([
service('request_stack'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ interface AuthorizationCheckerInterface
/**
* Checks if the attribute is granted against the current authentication token and optionally supplied subject.
*
* @param mixed $attribute A single attribute to vote on (can be of any type, string and instance of Expression are supported by the core)
* @param mixed $attribute A single attribute to vote on (can be of any type; strings, Expression and Closure instances are supported by the core)
* @param AccessDecision|null $accessDecision Should be used to explain the decision
*/
public function isGranted(mixed $attribute, mixed $subject = null/* , ?AccessDecision $accessDecision = null */): bool;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Security\Core\Authorization\Voter;

use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;

/**
* This voter allows using a closure as the attribute being voted on.
*
* The following named arguments are passed to the closure:
*
* - `token`: The token being used for voting
* - `subject`: The subject of the vote
* - `accessDecisionManager`: The access decision manager
* - `trustResolver`: The trust resolver
*
* @see IsGranted doc for the complete closure signature.
*
* @author Alexandre Daubois <alex.daubois@gmail.com>
*/
final class ClosureVoter implements CacheableVoterInterface
{
public function __construct(
private AccessDecisionManagerInterface $accessDecisionManager,
private AuthenticationTrustResolverInterface $trustResolver,
) {
}

public function supportsAttribute(string $attribute): bool

Check failure on line 41 in src/Symfony/Component/Security/Core/Authorization/Voter/ClosureVoter.php

View workflow job for this annotation

GitHub Actions / Psalm

MissingOverrideAttribute

src/Symfony/Component/Security/Core/Authorization/Voter/ClosureVoter.php:41:5: MissingOverrideAttribute: Method Symfony\Component\Security\Core\Authorization\Voter\ClosureVoter::supportsattribute should have the "Override" attribute (see https://psalm.dev/358)

Check failure on line 41 in src/Symfony/Component/Security/Core/Authorization/Voter/ClosureVoter.php

View workflow job for this annotation

GitHub Actions / Psalm

MissingOverrideAttribute

src/Symfony/Component/Security/Core/Authorization/Voter/ClosureVoter.php:41:5: MissingOverrideAttribute: Method Symfony\Component\Security\Core\Authorization\Voter\ClosureVoter::supportsattribute should have the "Override" attribute (see https://psalm.dev/358)
{
return false;
}

public function supportsType(string $subjectType): bool

Check failure on line 46 in src/Symfony/Component/Security/Core/Authorization/Voter/ClosureVoter.php

View workflow job for this annotation

GitHub Actions / Psalm

MissingOverrideAttribute

src/Symfony/Component/Security/Core/Authorization/Voter/ClosureVoter.php:46:5: MissingOverrideAttribute: Method Symfony\Component\Security\Core\Authorization\Voter\ClosureVoter::supportstype should have the "Override" attribute (see https://psalm.dev/358)

Check failure on line 46 in src/Symfony/Component/Security/Core/Authorization/Voter/ClosureVoter.php

View workflow job for this annotation

GitHub Actions / Psalm

MissingOverrideAttribute

src/Symfony/Component/Security/Core/Authorization/Voter/ClosureVoter.php:46:5: MissingOverrideAttribute: Method Symfony\Component\Security\Core\Authorization\Voter\ClosureVoter::supportstype should have the "Override" attribute (see https://psalm.dev/358)
{
return true;
}

public function vote(TokenInterface $token, mixed $subject, array $attributes, ?Vote $vote = null): int

Check failure on line 51 in src/Symfony/Component/Security/Core/Authorization/Voter/ClosureVoter.php

View workflow job for this annotation

GitHub Actions / Psalm

MissingOverrideAttribute

src/Symfony/Component/Security/Core/Authorization/Voter/ClosureVoter.php:51:5: MissingOverrideAttribute: Method Symfony\Component\Security\Core\Authorization\Voter\ClosureVoter::vote should have the "Override" attribute (see https://psalm.dev/358)

Check failure on line 51 in src/Symfony/Component/Security/Core/Authorization/Voter/ClosureVoter.php

View workflow job for this annotation

GitHub Actions / Psalm

MissingOverrideAttribute

src/Symfony/Component/Security/Core/Authorization/Voter/ClosureVoter.php:51:5: MissingOverrideAttribute: Method Symfony\Component\Security\Core\Authorization\Voter\ClosureVoter::vote should have the "Override" attribute (see https://psalm.dev/358)
{
$vote ??= new Vote();
$failingClosures = [];
$result = VoterInterface::ACCESS_ABSTAIN;
foreach ($attributes as $attribute) {
if (!$attribute instanceof \Closure) {
continue;
}

$name = (new \ReflectionFunction($attribute))->name;
$result = VoterInterface::ACCESS_DENIED;
if ($attribute(token: $token, subject: $subject, accessDecisionManager: $this->accessDecisionManager, trustResolver: $this->trustResolver)) {
$vote->reasons[] = \sprintf('Closure %s returned true.', $name);

return VoterInterface::ACCESS_GRANTED;
}

$failingClosures[] = $name;
}

if ($failingClosures) {
$vote->reasons[] = \sprintf('Closure%s %s returned false.', 1 < \count($failingClosures) ? 's' : '', implode(', ', $failingClosures));
}

return $result;
}
}
1 change: 1 addition & 0 deletions src/Symfony/Component/Security/Core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ CHANGELOG
* Deprecate `UserInterface::eraseCredentials()` and `TokenInterface::eraseCredentials()`,
erase credentials e.g. using `__serialize()` instead
* Add ability for voters to explain their vote
* Add support for voting on closures

7.2
---
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Security\Core\Tests\Authorization\Voter;

use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterfac A3E2 e;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
use Symfony\Component\Security\Core\Authorization\Voter\ClosureVoter;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Security\Core\User\UserInterface;

class ClosureVoterTest extends TestCase
{
private ClosureVoter $voter;

protected function setUp(): void
{
$this->voter = new ClosureVoter(
$this->createMock(AccessDecisionManagerInterface::class),
$this->createMock(AuthenticationTrustResolverInterface::class),
);
}

public function testEmptyAttributeAbstains()
{
$this->assertSame(VoterInterface::ACCESS_ABSTAIN, $this->voter->vote(
$this->createMock(TokenInterface::class),
null,
[])
);
}

public function testClosureReturningFalseDeniesAccess()
{
$token = $this->createMock(TokenInterface::class);
$token->method('getRoleNames')->willReturn([]);
$token->method('getUser')->willReturn($this->createMock(UserInterface::class));

$this->assertSame(VoterInterface::ACCESS_DENIED, $this->voter->vote(
$token,
null,
[fn (...$vars) => false]
));
}

public function testClosureReturningTrueGrantsAccess()
{
$token = $this->createMock(TokenInterface::class);
$token->method('getRoleNames')->willReturn([]);
$token->method('getUser')->willReturn($this->createMock(UserInterface::class));

$this->assertSame(VoterInterface::ACCESS_GRANTED, $this->voter->vote(
$token,
null,
[fn (...$vars) => true]
));
}

public function testArgumentsContent()
{
$token = $this->createMock(TokenInterface::class);
$token->method('getRoleNames')->willReturn(['MY_ROLE', 'ANOTHER_ROLE']);
$token->method('getUser')->willReturn($this->createMock(UserInterface::class));

$outerSubject = new \stdClass();

$this->voter->vote(
$token,
$outerSubject,
[function (...$vars) use ($outerSubject) {
$this->assertInstanceOf(TokenInterface::class, $vars['token']);
$this->assertSame($outerSubject, $vars['subject']);

$this->assertInstanceOf(AccessDecisionManagerInterface::class, $vars['accessDecisionManager']);
$this->assertInstanceOf(AuthenticationTrustResolverInterface::class, $vars['trustResolver']);

return true;
}]
);
}
}
F438
18 changes: 11 additions & 7 deletions src/Symfony/Component/Security/Http/Attribute/IsGranted.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
namespace Symfony\Component\Security\Http\Attribute;

use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;

/**
* Checks if user has permission to access to some resource using security roles and voters.
Expand All @@ -24,15 +28,15 @@
final class IsGranted
{
/**
* @param string|Expression $attribute The attribute that will be checked against a given authentication token and optional subject
* @param array|string|Expression|null $subject An optional subject - e.g. the current object being voted on
* @param string|null $message A custom message when access is not granted
* @param int|null $statusCode If set, will throw HttpKernel's HttpException with the given $statusCode; if null, Security\Core's AccessDeniedException will be used
* @param int|null $exceptionCode If set, will add the exception code to thrown exception
* @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
* @param array|string|Expression|(\Closure(array<string, mixed>, Request): mixed)|null $subject An optional subject - e.g. the current object being voted on
* @param string|null $message A custom message when access is not granted
* @param int|null $statusCode If set, will throw HttpKernel's HttpException with the given $statusCode; if null, Security\Core's AccessDeniedException will be used
* @param int|null $exceptionCode If set, will add the exception code to thrown exception
*/
public function __construct(
public string|Expression $attribute,
public array|string|Expression|null $subject = null,
public string|Expression|\Closure $attribute,
public array|string|Expression|\Closure|null $subject = null,
public ?string $message = null,
public ?int $statusCode = null,
public ?int $exceptionCode = null,
Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Component/Security/Http/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ CHANGELOG
* Replace `$hideAccountStatusExceptions` argument with `$exposeSecurityErrors` in `AuthenticatorManager` constructor
* Add argument `$identifierNormalizer` to `UserBadge::__construct()` to allow normalizing the identifier
* Support hashing the hashed password using crc32c when putting the user in the session
* Add support for closures in `#[IsGranted]`

7.2
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo
foreach ($subjectRef as $refKey => $ref) {
$subject[\is_string($refKey) ? $refKey : (string) $ref] = $this->getIsGrantedSubject($ref, $request, $arguments);
}
} elseif ($subjectRef instanceof \Closure) {
$subject = $subjectRef($arguments, $request);
} else {
$subject = $this->getIsGrantedSubject($subjectRef, $request, $arguments);
}
Expand All @@ -69,7 +71,7 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo
}

$e = new AccessDeniedException($message, code: $attribute->exceptionCode ?? 403);
$e->setAttributes($attribute->attribute);
$e->setAttributes([$attribute->attribute]);
$e->setSubject($subject);
$e->setAccessDecision($accessDecision);

Expand Down
Loading
Loading
0