Skip to content

Commit 1aead14

Browse files
florentdestremaunicolas-grekas
authored andcommitted
[Security][TwigBridge] Add access_decision() and access_decision_for_user()
1 parent d2d869f commit 1aead14

File tree

6 files changed

+216
-1
lines changed

6 files changed

+216
-1
lines changed

src/Symfony/Bridge/Twig/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.4
5+
---
6+
7+
* Add `access_decision()` and `access_decision_for_user()` Twig functions
8+
49
7.3
510
---
611

src/Symfony/Bridge/Twig/Extension/SecurityExtension.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ public function isGranted(mixed $role, mixed $object = null, ?string $field = nu
5555
}
5656
}
5757

58+
public function getAccessDecision(mixed $role, mixed $object = null, ?string $field = null): AccessDecision
59+
{
60+
if (!class_exists(AccessDecision::class)) {
61+
throw new \LogicException(\sprintf('Using the "access_decision()" function requires symfony/security-core >= 7.3. Try running "composer %s symfony/security-core".', $this->securityChecker ? 'update' : 'require'));
62+
}
63+
64+
$accessDecision = new AccessDecision();
65+
$this->isGranted($role, $object, $field, $accessDecision);
66+
67+
return $accessDecision;
68+
}
69+
5870
public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?string $field = null, ?AccessDecision $accessDecision = null): bool
5971
{
6072
if (null === $this->securityChecker) {
@@ -80,6 +92,18 @@ public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $s
8092
}
8193
}
8294

95+
public function getAccessDecisionForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?string $field = null): AccessDecision
96+
{
97+
if (!class_exists(AccessDecision::class)) {
98+
throw new \LogicException(\sprintf('Using the "access_decision_for_user()" function requires symfony/security-core >= 7.3. Try running "composer %s symfony/security-core".', $this->securityChecker ? 'bump' : 'require'));
99+
}
100+
101+
$accessDecision = new AccessDecision();
102+
$this->isGrantedForUser($user, $attribute, $subject, $field, $accessDecision);
103+
104+
return $accessDecision;
105+
}
106+
83107
public function getImpersonateExitUrl(?string $exitTo = null): string
84108
{
85109
if (null === $this->impersonateUrlGenerator) {
@@ -120,6 +144,7 @@ public function getFunctions(): array
120144
{
121145
$functions = [
122146
new TwigFunction('is_granted', $this->isGranted(...)),
147+
new TwigFunction('access_decision', $this->getAccessDecision(...)),
123148
new TwigFunction('impersonation_exit_url', $this->getImpersonateExitUrl(...)),
124149
new TwigFunction('impersonation_exit_path', $this->getImpersonateExitPath(...)),
125150
new TwigFunction('impersonation_url', $this->getImpersonateUrl(...)),
@@ -128,6 +153,7 @@ public function getFunctions(): array
128153

129154
if ($this->securityChecker instanceof UserAuthorizationCheckerInterface) {
130155
$functions[] = new TwigFunction('is_granted_for_user', $this->isGrantedForUser(...));
156+
$functions[] = new TwigFunction('access_decision_for_user', $this->getAccessDecisionForUser(...));
131157
}
132158

133159
return $functions;

src/Symfony/Bridge/Twig/Tests/Extension/SecurityExtensionTest.php

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public static function setUpBeforeClass(): void
3030

3131
protected function tearDown(): void
3232
{
33-
ClassExistsMock::withMockedClasses([FieldVote::class => true]);
33+
ClassExistsMock::withMockedClasses([FieldVote::class => true, AccessDecision::class => true]);
3434
}
3535

3636
#[DataProvider('provideObjectFieldAclCases')]
@@ -115,6 +115,122 @@ public function testIsGrantedForUserThrowsWhenFieldNotNullAndFieldVoteClassDoesN
115115
$securityExtension->isGrantedForUser($this->createMock(UserInterface::class), 'ROLE', 'object', 'bar');
116116
}
117117

118+
public function testAccessDecision()
119+
{
120+
if (!class_exists(AccessDecision::class)) {
121+
$this->markTestSkipped('This test requires symfony/security-core 7.3 or superior.');
122+
}
123+
124+
$securityChecker = $this->createMock(AuthorizationCheckerInterface::class);
125+
$securityChecker
126+
->expects($this->once())
127+
->method('isGranted')
128+
->with('ROLE', 'object', $this->isInstanceOf(AccessDecision::class))
129+
->willReturnCallback(function ($attribute, $subject, $accessDecision) {
130+
$accessDecision->isGranted = true;
131+
132+
return true;
133+
});
134+
135+
$securityExtension = new SecurityExtension($securityChecker);
136+
$accessDecision = $securityExtension->getAccessDecision('ROLE', 'object');
137+
138+
$this->assertInstanceOf(AccessDecision::class, $accessDecision);
139+
$this->assertTrue($accessDecision->isGranted);
140+
}
141+
142+
public function testAccessDecisionWithField()
143+
{
144+
if (!class_exists(AccessDecision::class)) {
145+
$this->markTestSkipped('This test requires symfony/security-core 7.3 or superior.');
146+
}
147+
148+
$securityChecker = $this->createMock(AuthorizationCheckerInterface::class);
149+
$securityChecker
150+
->expects($this->once())
151+
->method('isGranted')
152+
->with('ROLE', $this->isInstanceOf(FieldVote::class), $this->isInstanceOf(AccessDecision::class))
153+
->willReturnCallback(function ($attribute, $subject, $accessDecision) {
154+
$accessDecision->isGranted = false;
155+
156+
return false;
157+
});
158+
159+
$securityExtension = new SecurityExtension($securityChecker);
160+
$accessDecision = $securityExtension->getAccessDecision('ROLE', 'object', 'field');
161+
162+
$this->assertInstanceOf(AccessDecision::class, $accessDecision);
163+
$this->assertFalse($accessDecision->isGranted);
164+
}
165+
166+
public function testAccessDecisionThrowsWhenAccessDecisionClassDoesNotExist()
167+
{
168+
ClassExistsMock::withMockedClasses([AccessDecision::class => false]);
169+
170+
$securityChecker = $this->createMock(AuthorizationCheckerInterface::class);
171+
172+
$this->expectException(\LogicException::class);
173+
$this->expectExceptionMessage('Using the "access_decision()" function requires symfony/security-core >= 7.3. Try running "composer update symfony/security-core".');
174+
175+
$securityExtension = new SecurityExtension($securityChecker);
176+
$securityExtension->getAccessDecision('ROLE', 'object');
177+
}
178+
179+
public function testAccessDecisionForUser()
180+
{
181+
if (!interface_exists(UserAuthorizationCheckerInterface::class) || !class_exists(AccessDecision::class)) {
182+
$this->markTestSkipped('This test requires symfony/security-core 7.3 or superior.');
183+
}
184+
185+
$user = $this->createMock(UserInterface::class);
186+
$securityChecker = $this->createMockAuthorizationChecker();
187+
188+
$securityExtension = new SecurityExtension($securityChecker);
189+
$accessDecision = $securityExtension->getAccessDecisionForUser($user, 'ROLE', 'object');
190+
191+
$this->assertInstanceOf(AccessDecision::class, $accessDecision);
192+
$this->assertTrue($accessDecision->isGranted);
193+
$this->assertSame($user, $securityChecker->user);
194+
$this->assertSame('ROLE', $securityChecker->attribute);
195+
$this->assertSame('object', $securityChecker->subject);
196+
}
197+
198+
public function testAccessDecisionForUserWithField()
199+
{
200+
if (!interface_exists(UserAuthorizationCheckerInterface::class) || !class_exists(AccessDecision::class)) {
201+
$this->markTestSkipped('This test requires symfony/security-core 7.3 or superior.');
202+
}
203+
204+
$user = $this->createMock(UserInterface::class);
205+
$securityChecker = $this->createMockAuthorizationChecker();
206+
207+
$securityExtension = new SecurityExtension($securityChecker);
208+
$accessDecision = $securityExtension->getAccessDecisionForUser($user, 'ROLE', 'object', 'field');
209+
210+
$this->assertInstanceOf(AccessDecision::class, $accessDecision);
211+
$this->assertTrue($accessDecision->isGranted);
212+
$this->assertSame($user, $securityChecker->user);
213+
$this->assertSame('ROLE', $securityChecker->attribute);
214+
$this->assertEquals(new FieldVote('object', 'field'), $securityChecker->subject);
215+
}
216+
217+
public function testAccessDecisionForUserThrowsWhenAccessDecisionClassDoesNotExist()
218+
{
219+
if (!interface_exists(UserAuthorizationCheckerInterface::class)) {
220+
$this->markTestSkipped('This test requires symfony/security-core 7.3 or superior.');
221+
}
222+
223+
ClassExistsMock::withMockedClasses([AccessDecision::class => false]);
224+
225+
$securityChecker = $this->createMockAuthorizationChecker();
226+
$securityExtension = new SecurityExtension($securityChecker);
227+
228+
$this->expectException(\LogicException::class);
229+
$this->expectExceptionMessage('Using the "access_decision_for_user()" function requires symfony/security-core >= 7.3. Try running "composer update symfony/security-core".');
230+
231+
$securityExtension->getAccessDecisionForUser($this->createMock(UserInterface::class), 'ROLE', 'object');
232+
}
233+
118234
private function createMockAuthorizationChecker(): AuthorizationCheckerInterface&UserAuthorizationCheckerInterface
119235
{
120236
return new class implements AuthorizationCheckerInterface, UserAuthorizationCheckerInterface {
@@ -133,6 +249,10 @@ public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $s
133249
$this->attribute = $attribute;
134250
$this->subject = $subject;
135251

252+
if ($accessDecision) {
253+
$accessDecision->isGranted = true;
254+
}
255+
136256
return true;
137257
}
138258
};

src/Symfony/Bundle/SecurityBundle/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
7.4
55
---
66

7+
* Add `Security::getAccessDecision()` and `getAccessDecisionForUser()` helpers
78
* Add options to configure a cache pool and storage service for login throttling rate limiters
89
* Register alias for argument for password hasher when its key is not a class name:
910

src/Symfony/Bundle/SecurityBundle/Security.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ public function isGranted(mixed $attributes, mixed $subject = null, ?AccessDecis
6565
->isGranted($attributes, $subject, $accessDecision);
6666
}
6767

68+
public function getAccessDecision(mixed $attributes, mixed $subject = null): AccessDecision
69+
{
70+
$accessDecision = new AccessDecision();
71+
$this->isGranted($attributes, $subject, $accessDecision);
72+
73+
return $accessDecision;
74+
}
75+
6876
/**
6977
* Checks if the attribute is granted against the user and optionally supplied subject.
7078
*
@@ -76,6 +84,14 @@ public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $s
7684
->isGrantedForUser($user, $attribute, $subject, $accessDecision);
7785
}
7886

87+
public function getAccessDecisionForUser(UserInterface $user, mixed $attributes, mixed $subject = null): AccessDecision
88+
{
89+
$accessDecision = new AccessDecision();
90+
$this->isGrantedForUser($user, $attributes, $subject, $accessDecision);
91+
92+
return $accessDecision;
93+
}
94+
7995
public function getToken(): ?TokenInterface
8096
{
8197
return $this->container->get('security.token_storage')->getToken();

src/Symfony/Bundle/SecurityBundle/Tests/SecurityTest.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@
2525
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
2626
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
2727
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
28+
use Symfony\Component\Security\Core\Authorization\AccessDecision;
2829
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
30+
use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface;
2931
use Symfony\Component\Security\Core\Exception\LogicException;
3032
use Symfony\Component\Security\Core\User\InMemoryUser;
3133
use Symfony\Component\Security\Core\User\UserCheckerInterface;
@@ -98,6 +100,51 @@ public function testIsGranted()
98100
$this->assertTrue($security->isGranted('SOME_ATTRIBUTE', 'SOME_SUBJECT'));
99101
}
100102

103+
public function testAccessDecision()
104+
{
105+
$authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class);
106+
107+
$authorizationChecker->expects($this->once())
108+
->method('isGranted')
109+
->with('SOME_ATTRIBUTE', 'SOME_SUBJECT', $this->isInstanceOf(AccessDecision::class))
110+
->willReturnCallback(function ($attribute, $subject, $accessDecision) {
111+
$accessDecision->isGranted = true;
112+
113+
return true;
114+
});
115+
116+
$container = $this->createContainer('security.authorization_checker', $authorizationChecker);
117+
118+
$security = new Security($container);
119+
$accessDecision = $security->getAccessDecision('SOME_ATTRIBUTE', 'SOME_SUBJECT');
120+
121+
$this->assertInstanceOf(AccessDecision::class, $accessDecision);
122+
$this->assertTrue($accessDecision->isGranted);
123+
}
124+
125+
public function testAccessDecisionForUser()
126+
{
127+
$user = new InMemoryUser('test_user', 'password');
128+
$userAuthorizationChecker = $this->createMock(UserAuthorizationCheckerInterface::class);
129+
130+
$userAuthorizationChecker->expects($this->once())
131+
->method('isGrantedForUser')
132+
->with($user, 'SOME_ATTRIBUTE', 'SOME_SUBJECT', $this->isInstanceOf(AccessDecision::class))
133+
->willReturnCallback(function ($user, $attribute, $subject, $accessDecision) {
134+
$accessDecision->isGranted = false;
135+
136+
return false;
137+
});
138+
139+
$container = $this->createContainer('security.user_authorization_checker', $userAuthorizationChecker);
140+
141+
$security = new Security($container);
142+
$accessDecision = $security->getAccessDecisionForUser($user, 'SOME_ATTRIBUTE', 'SOME_SUBJECT');
143+
144+
$this->assertInstanceOf(AccessDecision::class, $accessDecision);
145+
$this->assertFalse($accessDecision->isGranted);
146+
}
147+
101148
#[DataProvider('getFirewallConfigTests')]
102149
public function testGetFirewallConfig(Request $request, ?FirewallConfig $expectedFirewallConfig)
103150
{

0 commit comments

Comments
 (0)