From aa38eb85421345388a3802673e7c4a12ea951cf5 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 17 Feb 2025 16:31:27 +0100 Subject: [PATCH] [Security] Improve DX of recent additions --- .../Twig/Extension/SecurityExtension.php | 13 ++- .../Tests/Extension/SecurityExtensionTest.php | 87 +++++++++++----- .../Resources/config/security.php | 13 +-- .../Resources/config/templating_twig.php | 1 - .../Bundle/SecurityBundle/Security.php | 22 ++--- .../Token/UserAuthorizationCheckerToken.php | 31 ------ .../Authorization/AuthorizationChecker.php | 21 +++- .../UserAuthorizationChecker.php | 31 ------ .../Core/Authorization/Voter/ClosureVoter.php | 17 +--- .../Component/Security/Core/CHANGELOG.md | 3 +- .../UserAuthorizationCheckerTokenTest.php | 26 ----- .../AuthorizationCheckerTest.php | 39 ++++++++ .../UserAuthorizationCheckerTest.php | 70 ------------- .../Voter/AuthenticatedVoterTest.php | 4 +- .../Authorization/Voter/ClosureVoterTest.php | 22 ++--- .../Security/Http/Attribute/IsGranted.php | 7 +- .../Http/Attribute/IsGrantedContext.php | 48 +++++++++ .../IsGrantedAttributeListener.php | 8 +- ...antedAttributeWithClosureListenerTest.php} | 61 ++++++------ ...AttributeMethodsWithCallableController.php | 95 ------------------ ...dAttributeMethodsWithClosureController.php | 98 +++++++++++++++++++ ...GrantedAttributeWithClosureController.php} | 11 ++- 22 files changed, 350 insertions(+), 378 deletions(-) delete mode 100644 src/Symfony/Component/Security/Core/Authentication/Token/UserAuthorizationCheckerToken.php delete mode 100644 src/Symfony/Component/Security/Core/Authorization/UserAuthorizationChecker.php delete mode 100644 src/Symfony/Component/Security/Core/Tests/Authentication/Token/UserAuthorizationCheckerTokenTest.php delete mode 100644 src/Symfony/Component/Security/Core/Tests/Authorization/UserAuthorizationCheckerTest.php create mode 100644 src/Symfony/Component/Security/Http/Attribute/IsGrantedContext.php rename src/Symfony/Component/Security/Http/Tests/EventListener/{IsGrantedAttributeWithCallableListenerTest.php => IsGrantedAttributeWithClosureListenerTest.php} (78%) delete mode 100644 src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeMethodsWithCallableController.php create mode 100644 src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeMethodsWithClosureController.php rename src/Symfony/Component/Security/Http/Tests/Fixtures/{IsGrantedAttributeWithCallableController.php => IsGrantedAttributeWithClosureController.php} (57%) diff --git a/src/Symfony/Bridge/Twig/Extension/SecurityExtension.php b/src/Symfony/Bridge/Twig/Extension/SecurityExtension.php index 6bd8e764c78aa..e0bb242586371 100644 --- a/src/Symfony/Bridge/Twig/Extension/SecurityExtension.php +++ b/src/Symfony/Bridge/Twig/Extension/SecurityExtension.php @@ -31,7 +31,6 @@ final class SecurityExtension extends AbstractExtension public function __construct( private ?AuthorizationCheckerInterface $securityChecker = null, private ?ImpersonateUrlGenerator $impersonateUrlGenerator = null, - private ?UserAuthorizationCheckerInterface $userSecurityChecker = null, ) { } @@ -58,8 +57,12 @@ public function isGranted(mixed $role, mixed $object = null, ?string $field = nu public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?string $field = null, ?AccessDecision $accessDecision = null): bool { - if (!$this->userSecurityChecker) { - throw new \LogicException(\sprintf('An instance of "%s" must be provided to use "%s()".', UserAuthorizationCheckerInterface::class, __METHOD__)); + if (null === $this->securityChecker) { + return false; + } + + if (!$this->securityChecker instanceof UserAuthorizationCheckerInterface) { + throw new \LogicException(\sprintf('You cannot use "%s()" if the authorization checker doesn\'t implement "%s".%s', __METHOD__, UserAuthorizationCheckerInterface::class, interface_exists(UserAuthorizationCheckerInterface::class) ? ' Try upgrading the "symfony/security-core" package to v7.3 minimum.' : '')); } if (null !== $field) { @@ -71,7 +74,7 @@ public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $s } try { - return $this->userSecurityChecker->isGrantedForUser($user, $attribute, $subject, $accessDecision); + return $this->securityChecker->isGrantedForUser($user, $attribute, $subject, $accessDecision); } catch (AuthenticationCredentialsNotFoundException) { return false; } @@ -123,7 +126,7 @@ public function getFunctions(): array new TwigFunction('impersonation_path', $this->getImpersonatePath(...)), ]; - if ($this->userSecurityChecker) { + if ($this->securityChecker instanceof UserAuthorizationCheckerInterface) { $functions[] = new TwigFunction('is_granted_for_user', $this->isGrantedForUser(...)); } diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/SecurityExtensionTest.php b/src/Symfony/Bridge/Twig/Tests/Extension/SecurityExtensionTest.php index 2afa868f0364e..e0ca4dcbb6901 100644 --- a/src/Symfony/Bridge/Twig/Tests/Extension/SecurityExtensionTest.php +++ b/src/Symfony/Bridge/Twig/Tests/Extension/SecurityExtensionTest.php @@ -15,12 +15,23 @@ use Symfony\Bridge\PhpUnit\ClassExistsMock; use Symfony\Bridge\Twig\Extension\SecurityExtension; use Symfony\Component\Security\Acl\Voter\FieldVote; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface; use Symfony\Component\Security\Core\User\UserInterface; class SecurityExtensionTest extends TestCase { + public static function setUpBeforeClass(): void + { + ClassExistsMock::register(SecurityExtension::class); + } + + protected function tearDown(): void + { + ClassExistsMock::withMockedClasses([FieldVote::class => true]); + } + /** * @dataProvider provideObjectFieldAclCases */ @@ -39,17 +50,16 @@ public function testIsGrantedCreatesFieldVoteObjectWhenFieldNotNull($object, $fi public function testIsGrantedThrowsWhenFieldNotNullAndFieldVoteClassDoesNotExist() { - if (!class_exists(UserAuthorizationCheckerInterface::class)) { + if (!interface_exists(UserAuthorizationCheckerInterface::class)) { $this->markTestSkipped('This test requires symfony/security-core 7.3 or superior.'); } $securityChecker = $this->createMock(AuthorizationCheckerInterface::class); - ClassExistsMock::register(SecurityExtension::class); ClassExistsMock::withMockedClasses([FieldVote::class => false]); $this->expectException(\LogicException::class); - $this->expectExceptionMessageMatches('Passing a $field to the "is_granted()" function requires symfony/acl.'); + $this->expectExceptionMessage('Passing a $field to the "is_granted()" function requires symfony/acl.'); $securityExtension = new SecurityExtension($securityChecker); $securityExtension->isGranted('ROLE', 'object', 'bar'); @@ -60,49 +70,74 @@ public function testIsGrantedThrowsWhenFieldNotNullAndFieldVoteClassDoesNotExist */ public function testIsGrantedForUserCreatesFieldVoteObjectWhenFieldNotNull($object, $field, $expectedSubject) { - if (!class_exists(UserAuthorizationCheckerInterface::class)) { + if (!interface_exists(UserAuthorizationCheckerInterface::class)) { $this->markTestSkipped('This test requires symfony/security-core 7.3 or superior.'); } $user = $this->createMock(UserInterface::class); - $userSecurityChecker = $this->createMock(UserAuthorizationCheckerInterface::class); - $userSecurityChecker - ->expects($this->once()) - ->method('isGrantedForUser') - ->with($user, 'ROLE', $expectedSubject) - ->willReturn(true); + $securityChecker = $this->createMockAuthorizationChecker(); - $securityExtension = new SecurityExtension(null, null, $userSecurityChecker); + $securityExtension = new SecurityExtension($securityChecker); $this->assertTrue($securityExtension->isGrantedForUser($user, 'ROLE', $object, $field)); + $this->assertSame($user, $securityChecker->user); + $this->assertSame('ROLE', $securityChecker->attribute); + + if (null === $field) { + $this->assertSame($object, $securityChecker->subject); + } else { + $this->assertEquals($expectedSubject, $securityChecker->subject); + } + } + + public static function provideObjectFieldAclCases() + { + return [ + [null, null, null], + ['object', null, 'object'], + ['object', false, new FieldVote('object', false)], + ['object', 0, new FieldVote('object', 0)], + ['object', '', new FieldVote('object', '')], + ['object', 'field', new FieldVote('object', 'field')], + ]; } public function testIsGrantedForUserThrowsWhenFieldNotNullAndFieldVoteClassDoesNotExist() { - if (!class_exists(UserAuthorizationCheckerInterface::class)) { + if (!interface_exists(UserAuthorizationCheckerInterface::class)) { $this->markTestSkipped('This test requires symfony/security-core 7.3 or superior.'); } - $securityChecker = $this->createMock(UserAuthorizationCheckerInterface::class); + $securityChecker = $this->createMockAuthorizationChecker(); - ClassExistsMock::register(SecurityExtension::class); ClassExistsMock::withMockedClasses([FieldVote::class => false]); $this->expectException(\LogicException::class); - $this->expectExceptionMessageMatches('Passing a $field to the "is_granted_for_user()" function requires symfony/acl.'); + $this->expectExceptionMessage('Passing a $field to the "is_granted_for_user()" function requires symfony/acl.'); - $securityExtension = new SecurityExtension(null, null, $securityChecker); - $securityExtension->isGrantedForUser($this->createMock(UserInterface::class), 'object', 'bar'); + $securityExtension = new SecurityExtension($securityChecker); + $securityExtension->isGrantedForUser($this->createMock(UserInterface::class), 'ROLE', 'object', 'bar'); } - public static function provideObjectFieldAclCases() + private function createMockAuthorizationChecker(): AuthorizationCheckerInterface&UserAuthorizationCheckerInterface { - return [ - [null, null, null], - ['object', null, 'object'], - ['object', false, new FieldVote('object', false)], - ['object', 0, new FieldVote('object', 0)], - ['object', '', new FieldVote('object', '')], - ['object', 'field', new FieldVote('object', 'field')], - ]; + return new class implements AuthorizationCheckerInterface, UserAuthorizationCheckerInterface { + public UserInterface $user; + public mixed $attribute; + public mixed $subject; + + public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool + { + throw new \BadMethodCallException('This method should not be called.'); + } + + public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool + { + $this->user = $user; + $this->attribute = $attribute; + $this->subject = $subject; + + return true; + } + }; } } diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php index 100b498b5679e..7b08ebe5fa35d 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php @@ -31,7 +31,6 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationChecker; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Authorization\ExpressionLanguage; -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; @@ -69,12 +68,7 @@ service('security.access.decision_manager'), ]) ->alias(AuthorizationCheckerInterface::class, 'security.authorization_checker') - - ->set('security.user_authorization_checker', UserAuthorizationChecker::class) - ->args([ - service('security.access.decision_manager'), - ]) - ->alias(UserAuthorizationCheckerInterface::class, 'security.user_authorization_checker') + ->alias(UserAuthorizationCheckerInterface::class, 'security.authorization_checker') ->set('security.token_storage', UsageTrackingTokenStorage::class) ->args([ @@ -94,7 +88,7 @@ service_locator([ 'security.token_storage' => service('security.token_storage'), 'security.authorization_checker' => service('security.authorization_checker'), - 'security.user_authorization_checker' => service('security.user_authorization_checker'), + 'security.user_authorization_checker' => service('security.authorization_checker'), 'security.authenticator.managers_locator' => service('security.authenticator.managers_locator')->ignoreOnInvalid(), 'request_stack' => service('request_stack'), 'security.firewall.map' => service('security.firewall.map'), @@ -174,8 +168,7 @@ ->set('security.access.closure_voter', ClosureVoter::class) ->args([ - service('security.access.decision_manager'), - service('security.authentication.trust_resolver'), + service('security.authorization_checker'), ]) ->tag('security.voter', ['priority' => 245]) diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/templating_twig.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/templating_twig.php index 96a7a2833a443..05a74d086e820 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/templating_twig.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/templating_twig.php @@ -26,7 +26,6 @@ ->args([ service('security.authorization_checker')->ignoreOnInvalid(), service('security.impersonate_url_generator')->ignoreOnInvalid(), - service('security.user_authorization_checker')->ignoreOnInvalid(), ]) ->tag('twig.extension') ; diff --git a/src/Symfony/Bundle/SecurityBundle/Security.php b/src/Symfony/Bundle/SecurityBundle/Security.php index f1999ebb284b6..7f311f68d7d2b 100644 --- a/src/Symfony/Bundle/SecurityBundle/Security.php +++ b/src/Symfony/Bundle/SecurityBundle/Security.php @@ -65,6 +65,17 @@ public function isGranted(mixed $attributes, mixed $subject = null, ?AccessDecis ->isGranted($attributes, $subject, $accessDecision); } + /** + * Checks if the attribute is granted against the user and optionally supplied subject. + * + * This should be used over isGranted() when checking permissions against a user that is not currently logged in or while in a CLI context. + */ + public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool + { + return $this->container->get('security.user_authorization_checker') + ->isGrantedForUser($user, $attribute, $subject, $accessDecision); + } + public function getToken(): ?TokenInterface { return $this->container->get('security.token_storage')->getToken(); @@ -150,17 +161,6 @@ public function logout(bool $validateCsrfToken = true): ?Response return $logoutEvent->getResponse(); } - /** - * Checks if the attribute is granted against the user and optionally supplied subject. - * - * This should be used over isGranted() when checking permissions against a user that is not currently logged in or while in a CLI context. - */ - public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool - { - return $this->container->get('security.user_authorization_checker') - ->isGrantedForUser($user, $attribute, $subject, $accessDecision); - } - private function getAuthenticator(?string $authenticatorName, string $firewallName): AuthenticatorInterface { if (!isset($this->authenticators[$firewallName])) { diff --git a/src/Symfony/Component/Security/Core/Authentication/Token/UserAuthorizationCheckerToken.php b/src/Symfony/Component/Security/Core/Authentication/Token/UserAuthorizationCheckerToken.php deleted file mode 100644 index 2e84ce7ae3614..0000000000000 --- a/src/Symfony/Component/Security/Core/Authentication/Token/UserAuthorizationCheckerToken.php +++ /dev/null @@ -1,31 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Core\Authentication\Token; - -use Symfony\Component\Security\Core\User\UserInterface; - -/** - * UserAuthorizationCheckerToken implements a token used for checking authorization. - * - * @author Nate Wiebe - * - * @internal - */ -final class UserAuthorizationCheckerToken extends AbstractToken implements OfflineTokenInterface -{ - public function __construct(UserInterface $user) - { - parent::__construct($user->getRoles()); - - $this->setUser($user); - } -} diff --git a/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php b/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php index 3960f2bea87cc..3689bf5eb95b6 100644 --- a/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php +++ b/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php @@ -11,8 +11,11 @@ namespace Symfony\Component\Security\Core\Authorization; +use Symfony\Component\Security\Core\Authentication\Token\AbstractToken; use Symfony\Component\Security\Core\Authentication\Token\NullToken; +use Symfony\Component\Security\Core\Authentication\Token\OfflineTokenInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\User\UserInterface; /** * AuthorizationChecker is the main authorization point of the Security component. @@ -22,8 +25,9 @@ * @author Fabien Potencier * @author Johannes M. Schmitt */ -class AuthorizationChecker implements AuthorizationCheckerInterface +class AuthorizationChecker implements AuthorizationCheckerInterface, UserAuthorizationCheckerInterface { + private array $tokenStack = []; private array $accessDecisionStack = []; public function __construct( @@ -34,7 +38,7 @@ public function __construct( final public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool { - $token = $this->tokenStorage->getToken(); + $token = end($this->tokenStack) ?: $this->tokenStorage->getToken(); if (!$token || !$token->getUser()) { $token = new NullToken(); @@ -48,4 +52,17 @@ final public function isGranted(mixed $attribute, mixed $subject = null, ?Access array_pop($this->accessDecisionStack); } } + + final public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool + { + $token = new class($user->getRoles()) extends AbstractToken implements OfflineTokenInterface {}; + $token->setUser($user); + $this->tokenStack[] = $token; + + try { + return $this->isGranted($attribute, $subject, $accessDecision); + } finally { + array_pop($this->tokenStack); + } + } } diff --git a/src/Symfony/Component/Security/Core/Authorization/UserAuthorizationChecker.php b/src/Symfony/Component/Security/Core/Authorization/UserAuthorizationChecker.php deleted file mode 100644 index f515e5cbdeaea..0000000000000 --- a/src/Symfony/Component/Security/Core/Authorization/UserAuthorizationChecker.php +++ /dev/null @@ -1,31 +0,0 @@ - - * - * 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; - -use Symfony\Component\Security\Core\Authentication\Token\UserAuthorizationCheckerToken; -use Symfony\Component\Security\Core\User\UserInterface; - -/** - * @author Nate Wiebe - */ -final class UserAuthorizationChecker implements UserAuthorizationCheckerInterface -{ - public function __construct( - private readonly AccessDecisionManagerInterface $accessDecisionManager, - ) { - } - - public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool - { - return $this->accessDecisionManager->decide(new UserAuthorizationCheckerToken($user), [$attribute], $subject, $accessDecision); - } -} diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/ClosureVoter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/ClosureVoter.php index 23140c804de3c..03a9f7571a571 100644 --- a/src/Symfony/Component/Security/Core/Authorization/Voter/ClosureVoter.php +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/ClosureVoter.php @@ -11,21 +11,14 @@ 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\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Http\Attribute\IsGranted; +use Symfony\Component\Security\Http\Attribute\IsGrantedContext; /** * 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 @@ -33,8 +26,7 @@ final class ClosureVoter implements CacheableVoterInterface { public function __construct( - private AccessDecisionManagerInterface $accessDecisionManager, - private AuthenticationTrustResolverInterface $trustResolver, + private AuthorizationCheckerInterface $authorizationChecker, ) { } @@ -51,6 +43,7 @@ public function supportsType(string $subjectType): bool public function vote(TokenInterface $token, mixed $subject, array $attributes, ?Vote $vote = null): int { $vote ??= new Vote(); + $context = new IsGrantedContext($token, $token->getUser(), $this->authorizationChecker); $failingClosures = []; $result = VoterInterface::ACCESS_ABSTAIN; foreach ($attributes as $attribute) { @@ -60,7 +53,7 @@ public function vote(TokenInterface $token, mixed $subject, array $attributes, ? $name = (new \ReflectionFunction($attribute))->name; $result = VoterInterface::ACCESS_DENIED; - if ($attribute(token: $token, subject: $subject, accessDecisionManager: $this->accessDecisionManager, trustResolver: $this->trustResolver)) { + if ($attribute($context, $subject)) { $vote->reasons[] = \sprintf('Closure %s returned true.', $name); return VoterInterface::ACCESS_GRANTED; diff --git a/src/Symfony/Component/Security/Core/CHANGELOG.md b/src/Symfony/Component/Security/Core/CHANGELOG.md index 12a0c0a6fc135..d3b3b652bb17d 100644 --- a/src/Symfony/Component/Security/Core/CHANGELOG.md +++ b/src/Symfony/Component/Security/Core/CHANGELOG.md @@ -4,8 +4,7 @@ CHANGELOG 7.3 --- - * Add `UserAuthorizationChecker::isGrantedForUser()` to test user authorization without relying on the session. - For example, users not currently logged in, or while processing a message from a message queue. + * Add `UserAuthorizationCheckerInterface` to test user authorization without relying on the session * Add `OfflineTokenInterface` to mark tokens that do not represent the currently logged-in user * Deprecate `UserInterface::eraseCredentials()` and `TokenInterface::eraseCredentials()`, erase credentials e.g. using `__serialize()` instead diff --git a/src/Symfony/Component/Security/Core/Tests/Authentication/Token/UserAuthorizationCheckerTokenTest.php b/src/Symfony/Component/Security/Core/Tests/Authentication/Token/UserAuthorizationCheckerTokenTest.php deleted file mode 100644 index 2e7e11bde58f6..0000000000000 --- a/src/Symfony/Component/Security/Core/Tests/Authentication/Token/UserAuthorizationCheckerTokenTest.php +++ /dev/null @@ -1,26 +0,0 @@ - - * - * 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\Authentication\Token; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\Security\Core\Authentication\Token\UserAuthorizationCheckerToken; -use Symfony\Component\Security\Core\User\InMemoryUser; - -class UserAuthorizationCheckerTokenTest extends TestCase -{ - public function testConstructor() - { - $token = new UserAuthorizationCheckerToken($user = new InMemoryUser('foo', 'bar', ['ROLE_FOO'])); - $this->assertSame(['ROLE_FOO'], $token->getRoleNames()); - $this->assertSame($user, $token->getUser()); - } -} diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/AuthorizationCheckerTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/AuthorizationCheckerTest.php index 36b048c8976d1..00f0f50e47ca3 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/AuthorizationCheckerTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/AuthorizationCheckerTest.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Security\Core\Authentication\Token\NullToken; +use Symfony\Component\Security\Core\Authentication\Token\OfflineTokenInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; @@ -77,4 +78,42 @@ public function testIsGrantedWithObjectAttribute() $this->tokenStorage->setToken($token); $this->assertTrue($this->authorizationChecker->isGranted($attribute)); } + + /** + * @dataProvider isGrantedForUserProvider + */ + public function testIsGrantedForUser(bool $decide, array $roles) + { + $user = new InMemoryUser('username', 'password', $roles); + + $this->accessDecisionManager + ->expects($this->once()) + ->method('decide') + ->with($this->callback(static fn (OfflineTokenInterface $token) => $token->getUser() === $user), ['ROLE_FOO']) + ->willReturn($decide); + + $this->assertSame($decide, $this->authorizationChecker->isGrantedForUser($user, 'ROLE_FOO')); + } + + public static function isGrantedForUserProvider(): array + { + return [ + [false, ['ROLE_USER']], + [true, ['ROLE_USER', 'ROLE_FOO']], + ]; + } + + public function testIsGrantedForUserWithObjectAttribute() + { + $attribute = new \stdClass(); + + $user = new InMemoryUser('username', 'password', ['ROLE_USER']); + + $this->accessDecisionManager + ->expects($this->once()) + ->method('decide') + ->with($this->isInstanceOf(OfflineTokenInterface::class), [$attribute]) + ->willReturn(true); + $this->assertTrue($this->authorizationChecker->isGrantedForUser($user, $attribute)); + } } diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/UserAuthorizationCheckerTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/UserAuthorizationCheckerTest.php deleted file mode 100644 index e9b6bb74bfe6f..0000000000000 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/UserAuthorizationCheckerTest.php +++ /dev/null @@ -1,70 +0,0 @@ - - * - * 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; - -use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; -use Symfony\Component\Security\Core\Authentication\Token\UserAuthorizationCheckerToken; -use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; -use Symfony\Component\Security\Core\Authorization\UserAuthorizationChecker; -use Symfony\Component\Security\Core\User\InMemoryUser; - -class UserAuthorizationCheckerTest extends TestCase -{ - private AccessDecisionManagerInterface&MockObject $accessDecisionManager; - private UserAuthorizationChecker $authorizationChecker; - - protected function setUp(): void - { - $this->accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class); - - $this->authorizationChecker = new UserAuthorizationChecker($this->accessDecisionManager); - } - - /** - * @dataProvider isGrantedProvider - */ - public function testIsGranted(bool $decide, array $roles) - { - $user = new InMemoryUser('username', 'password', $roles); - - $this->accessDecisionManager - ->expects($this->once()) - ->method('decide') - ->with($this->callback(fn (UserAuthorizationCheckerToken $token): bool => $user === $token->getUser()), $this->identicalTo(['ROLE_FOO'])) - ->willReturn($decide); - - $this->assertSame($decide, $this->authorizationChecker->isGrantedForUser($user, 'ROLE_FOO')); - } - - public static function isGrantedProvider(): array - { - return [ - [false, ['ROLE_USER']], - [true, ['ROLE_USER', 'ROLE_FOO']], - ]; - } - - public function testIsGrantedWithObjectAttribute() - { - $attribute = new \stdClass(); - - $token = new UserAuthorizationCheckerToken(new InMemoryUser('username', 'password', ['ROLE_USER'])); - - $this->accessDecisionManager - ->expects($this->once()) - ->method('decide') - ->with($this->isInstanceOf($token::class), $this->identicalTo([$attribute])) - ->willReturn(true); - $this->assertTrue($this->authorizationChecker->isGrantedForUser($token->getUser(), $attribute)); - } -} diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/AuthenticatedVoterTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/AuthenticatedVoterTest.php index 89f6c35007520..b5e0bf429fcd7 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/AuthenticatedVoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/AuthenticatedVoterTest.php @@ -15,9 +15,9 @@ use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolver; use Symfony\Component\Security\Core\Authentication\Token\AbstractToken; use Symfony\Component\Security\Core\Authentication\Token\NullToken; +use Symfony\Component\Security\Core\Authentication\Token\OfflineTokenInterface; use Symfony\Component\Security\Core\Authentication\Token\RememberMeToken; use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken; -use Symfony\Component\Security\Core\Authentication\Token\UserAuthorizationCheckerToken; use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Exception\InvalidArgumentException; @@ -148,7 +148,7 @@ public function getCredentials() } if ('offline' === $authenticated) { - return new UserAuthorizationCheckerToken($user); + return new class($user->getRoles()) extends AbstractToken implements OfflineTokenInterface {}; } return new NullToken(); diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/ClosureVoterTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/ClosureVoterTest.php index a919916a55ae3..7a22f2d4b54cd 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/ClosureVoterTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/ClosureVoterTest.php @@ -12,13 +12,16 @@ 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\TokenInterface; -use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Authorization\Voter\ClosureVoter; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Http\Attribute\IsGrantedContext; +/** + * @requires function Symfony\Component\Security\Http\Attribute\IsGrantedContext::isGranted + */ class ClosureVoterTest extends TestCase { private ClosureVoter $voter; @@ -26,8 +29,7 @@ class ClosureVoterTest extends TestCase protected function setUp(): void { $this->voter = new ClosureVoter( - $this->createMock(AccessDecisionManagerInterface::class), - $this->createMock(AuthenticationTrustResolverInterface::class), + $this->createMock(AuthorizationCheckerInterface::class), ); } @@ -49,7 +51,7 @@ public function testClosureReturningFalseDeniesAccess() $this->assertSame(VoterInterface::ACCESS_DENIED, $this->voter->vote( $token, null, - [fn (...$vars) => false] + [fn () => false] )); } @@ -62,7 +64,7 @@ public function testClosureReturningTrueGrantsAccess() $this->assertSame(VoterInterface::ACCESS_GRANTED, $this->voter->vote( $token, null, - [fn (...$vars) => true] + [fn () => true] )); } @@ -77,12 +79,8 @@ public function testArgumentsContent() $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']); + [function (IsGrantedContext $context, \stdClass $subject) use ($outerSubject) { + $this->assertSame($outerSubject, $subject); return true; }] diff --git a/src/Symfony/Component/Security/Http/Attribute/IsGranted.php b/src/Symfony/Component/Security/Http/Attribute/IsGranted.php index 546f293b662ea..7f3fef6941211 100644 --- a/src/Symfony/Component/Security/Http/Attribute/IsGranted.php +++ b/src/Symfony/Component/Security/Http/Attribute/IsGranted.php @@ -13,9 +13,6 @@ 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. @@ -28,8 +25,8 @@ final class IsGranted { /** - * @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, Request): mixed)|null $subject An optional subject - e.g. the current object being voted on + * @param string|Expression|\Closure(IsGrantedContext, mixed $subject):bool $attribute The attribute that will be checked against a given authentication token and optional subject + * @param array|string|Expression|\Closure(array, 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 diff --git a/src/Symfony/Component/Security/Http/Attribute/IsGrantedContext.php b/src/Symfony/Component/Security/Http/Attribute/IsGrantedContext.php new file mode 100644 index 0000000000000..fa2ce4a0f5ec8 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Attribute/IsGrantedContext.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Attribute; + +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\AccessDecision; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter; +use Symfony\Component\Security\Core\User\UserInterface; + +readonly class IsGrantedContext implements AuthorizationCheckerInterface +{ + public function __construct( + public TokenInterface $token, + public ?UserInterface $user, + private AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool + { + return $this->authorizationChecker->isGranted($attribute, $subject, $accessDecision); + } + + public function isAuthenticated(): bool + { + return $this->authorizationChecker->isGranted(AuthenticatedVoter::IS_AUTHENTICATED); + } + + public function isAuthenticatedFully(): bool + { + return $this->authorizationChecker->isGranted(AuthenticatedVoter::IS_AUTHENTICATED_FULLY); + } + + public function isImpersonator(): bool + { + return $this->authorizationChecker->isGranted(AuthenticatedVoter::IS_IMPERSONATOR); + } +} diff --git a/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php b/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php index e79a2e9425e07..607643cef3d5c 100644 --- a/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php +++ b/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php @@ -55,8 +55,6 @@ 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); } @@ -85,8 +83,12 @@ public static function getSubscribedEvents(): array return [KernelEvents::CONTROLLER_ARGUMENTS => ['onKernelControllerArguments', 20]]; } - private function getIsGrantedSubject(string|Expression $subjectRef, Request $request, array $arguments): mixed + private function getIsGrantedSubject(string|Expression|\Closure $subjectRef, Request $request, array $arguments): mixed { + if ($subjectRef instanceof \Closure) { + return $subjectRef($arguments, $request); + } + if ($subjectRef instanceof Expression) { $this->expressionLanguage ??= new ExpressionLanguage(); diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeWithCallableListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeWithClosureListenerTest.php similarity index 78% rename from src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeWithCallableListenerTest.php rename to src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeWithClosureListenerTest.php index 8b80736e033ca..2ea375ae537e4 100644 --- a/src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeWithCallableListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeWithClosureListenerTest.php @@ -16,16 +16,20 @@ use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; +use Symfony\Component\Security\Core\Authorization\AccessDecisionManager; +use Symfony\Component\Security\Core\Authorization\AuthorizationChecker; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Authorization\Voter\ClosureVoter; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Http\EventListener\IsGrantedAttributeListener; -use Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeMethodsWithCallableController; -use Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeWithCallableController; +use Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeMethodsWithClosureController; +use Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeWithClosureController; /** * @requires PHP 8.5 */ -class IsGrantedAttributeWithCallableListenerTest extends TestCase +class IsGrantedAttributeWithClosureListenerTest extends TestCase { public function testAttribute() { @@ -36,7 +40,7 @@ public function testAttribute() $event = new ControllerArgumentsEvent( $this->createMock(HttpKernelInterface::class), - [new IsGrantedAttributeWithCallableController(), 'foo'], + [new IsGrantedAttributeWithClosureController(), 'foo'], [], new Request(), null @@ -52,7 +56,7 @@ public function testAttribute() $event = new ControllerArgumentsEvent( $this->createMock(HttpKernelInterface::class), - [new IsGrantedAttributeWithCallableController(), 'bar'], + [new IsGrantedAttributeWithClosureController(), 'bar'], [], new Request(), null @@ -70,7 +74,7 @@ public function testNothingHappensWithNoConfig() $event = new ControllerArgumentsEvent( $this->createMock(HttpKernelInterface::class), - [new IsGrantedAttributeMethodsWithCallableController(), 'noAttribute'], + [new IsGrantedAttributeMethodsWithClosureController(), 'noAttribute'], [], new Request(), null @@ -90,7 +94,7 @@ public function testIsGrantedCalledCorrectly() $event = new ControllerArgumentsEvent( $this->createMock(HttpKernelInterface::class), - [new IsGrantedAttributeMethodsWithCallableController(), 'admin'], + [new IsGrantedAttributeMethodsWithClosureController(), 'admin'], [], new Request(), null @@ -111,7 +115,7 @@ public function testIsGrantedSubjectFromArguments() $event = new ControllerArgumentsEvent( $this->createMock(HttpKernelInterface::class), - [new IsGrantedAttributeMethodsWithCallableController(), 'withSubject'], + [new IsGrantedAttributeMethodsWithClosureController(), 'withSubject'], ['arg1Value', 'arg2Value'], new Request(), null @@ -136,7 +140,7 @@ public function testIsGrantedSubjectFromArgumentsWithArray() $event = new ControllerArgumentsEvent( $this->createMock(HttpKernelInterface::class), - [new IsGrantedAttributeMethodsWithCallableController(), 'withSubjectArray'], + [new IsGrantedAttributeMethodsWithClosureController(), 'withSubjectArray'], ['arg1Value', 'arg2Value'], new Request(), null @@ -157,7 +161,7 @@ public function testIsGrantedNullSubjectFromArguments() $event = new ControllerArgumentsEvent( $this->createMock(HttpKernelInterface::class), - [new IsGrantedAttributeMethodsWithCallableController(), 'withSubject'], + [new IsGrantedAttributeMethodsWithClosureController(), 'withSubject'], ['arg1Value', null], new Request(), null @@ -180,7 +184,7 @@ public function testIsGrantedArrayWithNullValueSubjectFromArguments() $event = new ControllerArgumentsEvent( $this->createMock(HttpKernelInterface::class), - [new IsGrantedAttributeMethodsWithCallableController(), 'withSubjectArray'], + [new IsGrantedAttributeMethodsWithClosureController(), 'withSubjectArray'], ['arg1Value', null], new Request(), null @@ -196,7 +200,7 @@ public function testExceptionWhenMissingSubjectAttribute() $event = new ControllerArgumentsEvent( $this->createMock(HttpKernelInterface::class), - [new IsGrantedAttributeMethodsWithCallableController(), 'withMissingSubject'], + [new IsGrantedAttributeMethodsWithClosureController(), 'withMissingSubject'], [], new Request(), null @@ -214,10 +218,9 @@ public function testExceptionWhenMissingSubjectAttribute() */ public function testAccessDeniedMessages(string|array|null $subject, string $method, int $numOfArguments, string $expectedMessage) { - $authChecker = $this->createMock(AuthorizationCheckerInterface::class); - $authChecker->expects($this->any()) - ->method('isGranted') - ->willReturn(false); + $authChecker = new AuthorizationChecker(new TokenStorage(), new AccessDecisionManager((function () use (&$authChecker) { + yield new ClosureVoter($authChecker); + })())); // avoid the error of the subject not being found in the request attributes $arguments = array_fill(0, $numOfArguments, 'bar'); @@ -225,7 +228,7 @@ public function testAccessDeniedMessages(string|array|null $subject, string $met $event = new ControllerArgumentsEvent( $this->createMock(HttpKernelInterface::class), - [new IsGrantedAttributeMethodsWithCallableController(), $method], + [new IsGrantedAttributeMethodsWithClosureController(), $method], $arguments, new Request(), null @@ -236,7 +239,7 @@ public function testAccessDeniedMessages(string|array|null $subject, string $met $this->fail(); } catch (AccessDeniedException $e) { $this->assertSame($expectedMessage, $e->getMessage()); - $this->assertIsCallable($e->getAttributes()[0]); + $this->assertInstanceOf(\Closure::class, $e->getAttributes()[0]); if (null !== $subject) { $this->assertSame($subject, $e->getSubject()); } else { @@ -247,11 +250,11 @@ public function testAccessDeniedMessages(string|array|null $subject, string $met public static function getAccessDeniedMessageTests() { - yield [null, 'admin', 0, 'Access Denied by #[IsGranted({closure:Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeMethodsWithCallableController::admin():23})] on controller']; - yield ['bar', 'withSubject', 2, 'Access Denied by #[IsGranted({closure:Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeMethodsWithCallableController::withSubject():30}, "arg2Name")] on controller']; - yield [['arg1Name' => 'bar', 'arg2Name' => 'bar'], 'withSubjectArray', 2, 'Access Denied by #[IsGranted({closure:Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeMethodsWithCallableController::withSubjectArray():37}, ["arg1Name", "arg2Name"])] on controller']; - yield ['bar', 'withCallableAsSubject', 1, 'Access Denied by #[IsGranted({closure:Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeMethodsWithCallableController::withCallableAsSubject():73}, {closure:Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeMethodsWithCallableController::withCallableAsSubject():76})] on controller']; - yield [['author' => 'bar', 'alias' => 'bar'], 'withNestArgsInSubject', 2, 'Access Denied by #[IsGranted({closure:Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeMethodsWithCallableController::withNestArgsInSubject():84}, {closure:Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeMethodsWithCallableController::withNestArgsInSubject():86})] on controller']; + yield [null, 'admin', 0, 'Access Denied. Closure {closure:Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeMethodsWithClosureController::admin():23} returned false.']; + yield ['bar', 'withSubject', 2, 'Access Denied. Closure {closure:Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeMethodsWithClosureController::withSubject():30} returned false.']; + yield [['arg1Name' => 'bar', 'arg2Name' => 'bar'], 'withSubjectArray', 2, 'Access Denied. Closure {closure:Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeMethodsWithClosureController::withSubjectArray():37} returned false.']; + yield ['bar', 'withClosureAsSubject', 1, 'Access Denied. Closure {closure:Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeMethodsWithClosureController::withClosureAsSubject():73} returned false.']; + yield [['author' => 'bar', 'alias' => 'bar'], 'withNestArgsInSubject', 2, 'Access Denied. Closure {closure:Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeMethodsWithClosureController::withNestArgsInSubject():85} returned false.']; } public function testNotFoundHttpException() @@ -263,7 +266,7 @@ public function testNotFoundHttpException() $event = new ControllerArgumentsEvent( $this->createMock(HttpKernelInterface::class), - [new IsGrantedAttributeMethodsWithCallableController(), 'notFound'], + [new IsGrantedAttributeMethodsWithClosureController(), 'notFound'], [], new Request(), null @@ -277,7 +280,7 @@ public function testNotFoundHttpException() $listener->onKernelControllerArguments($event); } - public function testIsGrantedWithCallableAsSubject() + public function testIsGrantedWithClosureAsSubject() { $request = new Request(); @@ -289,7 +292,7 @@ public function testIsGrantedWithCallableAsSubject() $event = new ControllerArgumentsEvent( $this->createMock(HttpKernelInterface::class), - [new IsGrantedAttributeMethodsWithCallableController(), 'withCallableAsSubject'], + [new IsGrantedAttributeMethodsWithClosureController(), 'withClosureAsSubject'], ['postVal'], $request, null @@ -311,7 +314,7 @@ public function testIsGrantedWithNestedExpressionInSubject() $event = new ControllerArgumentsEvent( $this->createMock(HttpKernelInterface::class), - [new IsGrantedAttributeMethodsWithCallableController(), 'withNestArgsInSubject'], + [new IsGrantedAttributeMethodsWithClosureController(), 'withNestArgsInSubject'], ['postVal', 'bar'], $request, null @@ -330,7 +333,7 @@ public function testHttpExceptionWithExceptionCode() $event = new ControllerArgumentsEvent( $this->createMock(HttpKernelInterface::class), - [new IsGrantedAttributeMethodsWithCallableController(), 'exceptionCodeInHttpException'], + [new IsGrantedAttributeMethodsWithClosureController(), 'exceptionCodeInHttpException'], [], new Request(), null @@ -354,7 +357,7 @@ public function testAccessDeniedExceptionWithExceptionCode() $event = new ControllerArgumentsEvent( $this->createMock(HttpKernelInterface::class), - [new IsGrantedAttributeMethodsWithCallableController(), 'exceptionCodeInAccessDeniedException'], + [new IsGrantedAttributeMethodsWithClosureController(), 'exceptionCodeInAccessDeniedException'], [], new Request(), null diff --git a/src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeMethodsWithCallableController.php b/src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeMethodsWithCallableController.php deleted file mode 100644 index 8fab789cbdede..0000000000000 --- a/src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeMethodsWithCallableController.php +++ /dev/null @@ -1,95 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Tests\Fixtures; - -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Http\Attribute\IsGranted; - -class IsGrantedAttributeMethodsWithCallableController -{ - public function noAttribute() - { - } - - #[IsGranted(static function ($token, $accessDecisionManager, ...$vars) { - return $accessDecisionManager->decide($token, ['ROLE_ADMIN']); - })] - public function admin() - { - } - - #[IsGranted(static function ($token, $accessDecisionManager, ...$vars) { - return $accessDecisionManager->decide($token, ['ROLE_ADMIN']); - }, subject: 'arg2Name')] - public function withSubject($arg1Name, $arg2Name) - { - } - - #[IsGranted(static function ($token, $accessDecisionManager, ...$vars) { - return $accessDecisionManager->decide($token, ['ROLE_ADMIN']); - }, subject: ['arg1Name', 'arg2Name'])] - public function withSubjectArray($arg1Name, $arg2Name) - { - } - - #[IsGranted(static function ($token, $accessDecisionManager, ...$vars) { - return $accessDecisionManager->decide($token, ['ROLE_ADMIN']); - }, subject: 'non_existent')] - public function withMissingSubject() - { - } - - #[IsGranted(static function ($token, $accessDecisionManager, ...$vars) { - return $accessDecisionManager->decide($token, ['ROLE_ADMIN']); - }, message: 'Not found', statusCode: 404)] - public function notFound() - { - } - - #[IsGranted(static function ($token, $accessDecisionManager, ...$vars) { - return $accessDecisionManager->decide($token, ['ROLE_ADMIN']); - }, message: 'Exception Code Http', statusCode: 404, exceptionCode: 10010)] - public function exceptionCodeInHttpException() - { - } - - #[IsGranted(static function ($token, $accessDecisionManager, ...$vars) { - return $accessDecisionManager->decide($token, ['ROLE_ADMIN']); - }, message: 'Exception Code Access Denied', exceptionCode: 10010)] - public function exceptionCodeInAccessDeniedException() - { - } - - #[IsGranted( - static function (TokenInterface $token, $subject, ...$vars) { - return $token->getUser() === $subject; - }, - subject: static function (array $args) { - return $args['post']; - } - )] - public function withCallableAsSubject($post) - { - } - - #[IsGranted(static function ($token, $subject, ...$vars) { - return $token->getUser() === $subject['author']; - }, subject: static function (array $args) { - return [ - 'author' => $args['post'], - 'alias' => 'bar', - ]; - })] - public function withNestArgsInSubject($post, $arg2Name) - { - } -} diff --git a/src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeMethodsWithClosureController.php b/src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeMethodsWithClosureController.php new file mode 100644 index 0000000000000..bf001c53d16cf --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeMethodsWithClosureController.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Fixtures; + +use Symfony\Component\Security\Http\Attribute\IsGranted; +use Symfony\Component\Security\Http\Attribute\IsGrantedContext; + +class IsGrantedAttributeMethodsWithClosureController +{ + public function noAttribute() + { + } + + #[IsGranted(static function (IsGrantedContext $context) { + return $context->isGranted('ROLE_ADMIN'); + })] + public function admin() + { + } + + #[IsGranted(static function (IsGrantedContext $context) { + return $context->isGranted('ROLE_ADMIN'); + }, subject: 'arg2Name')] + public function withSubject($arg1Name, $arg2Name) + { + } + + #[IsGranted(static function (IsGrantedContext $context) { + return $context->isGranted('ROLE_ADMIN'); + }, subject: ['arg1Name', 'arg2Name'])] + public function withSubjectArray($arg1Name, $arg2Name) + { + } + + #[IsGranted(static function (IsGrantedContext $context) { + return $context->isGranted('ROLE_ADMIN'); + }, subject: 'non_existent')] + public function withMissingSubject() + { + } + + #[IsGranted(static function (IsGrantedContext $context) { + return $context->isGranted('ROLE_ADMIN'); + }, message: 'Not found', statusCode: 404)] + public function notFound() + { + } + + #[IsGranted(static function (IsGrantedContext $context) { + return $context->isGranted('ROLE_ADMIN'); + }, message: 'Exception Code Http', statusCode: 404, exceptionCode: 10010)] + public function exceptionCodeInHttpException() + { + } + + #[IsGranted(static function (IsGrantedContext $context) { + return $context->isGranted('ROLE_ADMIN'); + }, message: 'Exception Code Access Denied', exceptionCode: 10010)] + public function exceptionCodeInAccessDeniedException() + { + } + + #[IsGranted( + static function (IsGrantedContext $context, mixed $subject) { + return $context->user === $subject; + }, + subject: static function (array $args) { + return $args['post']; + } + )] + public function withClosureAsSubject($post) + { + } + + #[IsGranted( + static function (IsGrantedContext $context, array $subject) { + return $context->user === $subject['author']; + }, + subject: static function (array $args) { + return [ + 'author' => $args['post'], + 'alias' => 'bar', + ]; + } + )] + public function withNestArgsInSubject($post, $arg2Name) + { + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeWithCallableController.php b/src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeWithClosureController.php similarity index 57% rename from src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeWithCallableController.php rename to src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeWithClosureController.php index 72c585d16c57b..61a1ddbc244b9 100644 --- a/src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeWithCallableController.php +++ b/src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeWithClosureController.php @@ -12,14 +12,15 @@ namespace Symfony\Component\Security\Http\Tests\Fixtures; use Symfony\Component\Security\Http\Attribute\IsGranted; +use Symfony\Component\Security\Http\Attribute\IsGrantedContext; -#[IsGranted(static function ($token, $accessDecisionManager, ...$vars) { - return $accessDecisionManager->decide($token, ['ROLE_USER']); +#[IsGranted(static function (IsGrantedContext $context) { + return $context->isGranted('ROLE_USER'); })] -class IsGrantedAttributeWithCallableController +class IsGrantedAttributeWithClosureController { - #[IsGranted(static function ($token, $accessDecisionManager, ...$vars) { - return $accessDecisionManager->decide($token, ['ROLE_ADMIN']); + #[IsGranted(static function (IsGrantedContext $context) { + return $context->isGranted('ROLE_ADMIN'); })] public function foo() {