From 9c31038dd66b83fbf1ff7ae8aae8f47cadfc8429 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Mon, 9 Dec 2024 08:56:13 +0100 Subject: [PATCH] [Security] Allow using a callable with `#[IsGranted]` --- .../Resources/config/security.php | 8 + .../AuthorizationCheckerInterface.php | 2 +- .../Core/Authorization/Voter/ClosureVoter.php | 78 ++++ .../Component/Security/Core/CHANGELOG.md | 1 + .../Authorization/Voter/ClosureVoterTest.php | 91 +++++ .../Security/Http/Attribute/IsGranted.php | 18 +- .../Component/Security/Http/CHANGELOG.md | 1 + .../IsGrantedAttributeListener.php | 4 +- ...antedAttributeWithCallableListenerTest.php | 371 ++++++++++++++++++ ...AttributeMethodsWithCallableController.php | 95 +++++ ...GrantedAttributeWithCallableController.php | 31 ++ 11 files changed, 691 insertions(+), 9 deletions(-) create mode 100644 src/Symfony/Component/Security/Core/Authorization/Voter/ClosureVoter.php create mode 100644 src/Symfony/Component/Security/Core/Tests/Authorization/Voter/ClosureVoterTest.php create mode 100644 src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeWithCallableListenerTest.php create mode 100644 src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeMethodsWithCallableController.php create mode 100644 src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeWithCallableController.php diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php index bd879973b49a3..100b498b5679e 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php @@ -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; @@ -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'), diff --git a/src/Symfony/Component/Security/Core/Authorization/AuthorizationCheckerInterface.php b/src/Symfony/Component/Security/Core/Authorization/AuthorizationCheckerInterface.php index 7c673dfc8a306..848b17eeb756e 100644 --- a/src/Symfony/Component/Security/Core/Authorization/AuthorizationCheckerInterface.php +++ b/src/Symfony/Component/Security/Core/Authorization/AuthorizationCheckerInterface.php @@ -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; diff --git a/src/Symfony/Component/Security/Core/Authorization/Voter/ClosureVoter.php b/src/Symfony/Component/Security/Core/Authorization/Voter/ClosureVoter.php new file mode 100644 index 0000000000000..23140c804de3c --- /dev/null +++ b/src/Symfony/Component/Security/Core/Authorization/Voter/ClosureVoter.php @@ -0,0 +1,78 @@ + + * + * 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 + */ +final class ClosureVoter implements CacheableVoterInterface +{ + public function __construct( + private AccessDecisionManagerInterface $accessDecisionManager, + private AuthenticationTrustResolverInterface $trustResolver, + ) { + } + + public function supportsAttribute(string $attribute): bool + { + return false; + } + + public function supportsType(string $subjectType): bool + { + return true; + } + + public function vote(TokenInterface $token, mixed $subject, array $attributes, ?Vote $vote = null): int + { + $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; + } +} diff --git a/src/Symfony/Component/Security/Core/CHANGELOG.md b/src/Symfony/Component/Security/Core/CHANGELOG.md index 331b204cc1ae8..12a0c0a6fc135 100644 --- a/src/Symfony/Component/Security/Core/CHANGELOG.md +++ b/src/Symfony/Component/Security/Core/CHANGELOG.md @@ -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 --- diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/ClosureVoterTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/ClosureVoterTest.php new file mode 100644 index 0000000000000..a919916a55ae3 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/ClosureVoterTest.php @@ -0,0 +1,91 @@ + + * + * 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\TokenInterface; +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; + }] + ); + } +} diff --git a/src/Symfony/Component/Security/Http/Attribute/IsGranted.php b/src/Symfony/Component/Security/Http/Attribute/IsGranted.php index c69ab0174e53a..546f293b662ea 100644 --- a/src/Symfony/Component/Security/Http/Attribute/IsGranted.php +++ b/src/Symfony/Component/Security/Http/Attribute/IsGranted.php @@ -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. @@ -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, 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, diff --git a/src/Symfony/Component/Security/Http/CHANGELOG.md b/src/Symfony/Component/Security/Http/CHANGELOG.md index 3a4f694af5027..22fe59f6892e3 100644 --- a/src/Symfony/Component/Security/Http/CHANGELOG.md +++ b/src/Symfony/Component/Security/Http/CHANGELOG.md @@ -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 --- diff --git a/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php b/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php index 5ac76c2ba9b02..e79a2e9425e07 100644 --- a/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php +++ b/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php @@ -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); } @@ -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); diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeWithCallableListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeWithCallableListenerTest.php new file mode 100644 index 0000000000000..8b80736e033ca --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeWithCallableListenerTest.php @@ -0,0 +1,371 @@ + + * + * 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\EventListener; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +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; + +/** + * @requires PHP 8.5 + */ +class IsGrantedAttributeWithCallableListenerTest extends TestCase +{ + public function testAttribute() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->exactly(2)) + ->method('isGranted') + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeWithCallableController(), 'foo'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeWithCallableController(), 'bar'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testNothingHappensWithNoConfig() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->never()) + ->method('isGranted'); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithCallableController(), 'noAttribute'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedCalledCorrectly() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + ->with($this->isInstanceOf(\Closure::class), null) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithCallableController(), 'admin'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedSubjectFromArguments() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + // the subject => arg2name will eventually resolve to the 2nd argument, which has this value + ->with($this->isInstanceOf(\Closure::class), 'arg2Value') + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithCallableController(), 'withSubject'], + ['arg1Value', 'arg2Value'], + new Request(), + null + ); + + // create metadata for 2 named args for the controller + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedSubjectFromArgumentsWithArray() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + // the subject => arg2name will eventually resolve to the 2nd argument, which has this value + ->with($this->isInstanceOf(\Closure::class), [ + 'arg1Name' => 'arg1Value', + 'arg2Name' => 'arg2Value', + ]) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithCallableController(), 'withSubjectArray'], + ['arg1Value', 'arg2Value'], + new Request(), + null + ); + + // create metadata for 2 named args for the controller + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedNullSubjectFromArguments() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + ->with($this->isInstanceOf(\Closure::class), null) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithCallableController(), 'withSubject'], + ['arg1Value', null], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedArrayWithNullValueSubjectFromArguments() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + ->with($this->isInstanceOf(\Closure::class), [ + 'arg1Name' => 'arg1Value', + 'arg2Name' => null, + ]) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithCallableController(), 'withSubjectArray'], + ['arg1Value', null], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testExceptionWhenMissingSubjectAttribute() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithCallableController(), 'withMissingSubject'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + + $this->expectException(\RuntimeException::class); + + $listener->onKernelControllerArguments($event); + } + + /** + * @dataProvider getAccessDeniedMessageTests + */ + 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); + + // avoid the error of the subject not being found in the request attributes + $arguments = array_fill(0, $numOfArguments, 'bar'); + $listener = new IsGrantedAttributeListener($authChecker); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithCallableController(), $method], + $arguments, + new Request(), + null + ); + + try { + $listener->onKernelControllerArguments($event); + $this->fail(); + } catch (AccessDeniedException $e) { + $this->assertSame($expectedMessage, $e->getMessage()); + $this->assertIsCallable($e->getAttributes()[0]); + if (null !== $subject) { + $this->assertSame($subject, $e->getSubject()); + } else { + $this->assertNull($e->getSubject()); + } + } + } + + 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']; + } + + public function testNotFoundHttpException() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->any()) + ->method('isGranted') + ->willReturn(false); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithCallableController(), 'notFound'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Not found'); + + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedWithCallableAsSubject() + { + $request = new Request(); + + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + ->with($this->isInstanceOf(\Closure::class), 'postVal') + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithCallableController(), 'withCallableAsSubject'], + ['postVal'], + $request, + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedWithNestedExpressionInSubject() + { + $request = new Request(); + + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + ->with($this->isInstanceOf(\Closure::class), ['author' => 'postVal', 'alias' => 'bar']) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithCallableController(), 'withNestArgsInSubject'], + ['postVal', 'bar'], + $request, + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testHttpExceptionWithExceptionCode() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->any()) + ->method('isGranted') + ->willReturn(false); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithCallableController(), 'exceptionCodeInHttpException'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Exception Code'); + $this->expectExceptionCode(10010); + + $listener->onKernelControllerArguments($event); + } + + public function testAccessDeniedExceptionWithExceptionCode() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->any()) + ->method('isGranted') + ->willReturn(false); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithCallableController(), 'exceptionCodeInAccessDeniedException'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + + $this->expectException(AccessDeniedException::class); + $this->expectExceptionMessage('Exception Code'); + $this->expectExceptionCode(10010); + + $listener->onKernelControllerArguments($event); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeMethodsWithCallableController.php b/src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeMethodsWithCallableController.php new file mode 100644 index 0000000000000..8fab789cbdede --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeMethodsWithCallableController.php @@ -0,0 +1,95 @@ + + * + * 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/IsGrantedAttributeWithCallableController.php b/src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeWithCallableController.php new file mode 100644 index 0000000000000..72c585d16c57b --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Fixtures/IsGrantedAttributeWithCallableController.php @@ -0,0 +1,31 @@ + + * + * 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; + +#[IsGranted(static function ($token, $accessDecisionManager, ...$vars) { + return $accessDecisionManager->decide($token, ['ROLE_USER']); +})] +class IsGrantedAttributeWithCallableController +{ + #[IsGranted(static function ($token, $accessDecisionManager, ...$vars) { + return $accessDecisionManager->decide($token, ['ROLE_ADMIN']); + })] + public function foo() + { + } + + public function bar() + { + } +}