Skip to content

Commit b488366

Browse files
authored
feat(symfony): Link security (#5290)
* [Link] Start Link Security * feat(provider): Auto Resolve Get Operation and Parameters * chore(CS): fix CS * feat(tests): Add DenyAccessListener tests * feat(tests): Add link security behat tests * fix(test): fix mongodb document configuration * fix(readlistner): fix error 500 on not existing entity * feat(linksecurity): expand functionality to cover all combinations of to and from property and add optional object name * feat(linksecurity): add more tests * chore: fix cs * chore: phpstan fix * fix: Move logic to refactored, now used, classes * fix: refactor unit tests * fix: backport for legacy event system as well * Revert "fix: backport for legacy event system as well" This reverts commit 16f14c8. * refactor: Refactor ReadProvider.php and AccessCheckerProvider.php to extract link security into their own providers * mark providers final, disable feature by default
1 parent 498cabb commit b488366

File tree

16 files changed

+605
-2
lines changed

16 files changed

+605
-2
lines changed

behat.yml.dist

+1-1
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ legacy:
203203
- 'Behat\MinkExtension\Context\MinkContext'
204204
- 'behatch:context:rest'
205205
filters:
206-
tags: '~@postgres&&~@mongodb&&~@elasticsearch'
206+
tags: '~@postgres&&~@mongodb&&~@elasticsearch&&~@link_security'
207207
extensions:
208208
'FriendsOfBehat\SymfonyExtension':
209209
bootstrap: 'tests/Fixtures/app/bootstrap.php'

features/authorization/deny.feature

+82
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,88 @@ Feature: Authorization checking
211211
And the response should contain "ownerOnlyProperty"
212212
And the JSON node "ownerOnlyProperty" should be equal to the string "updated"
213213

214+
@link_security
215+
Scenario: An non existing entity should return Not found
216+
When I add "Accept" header equal to "application/ld+json"
217+
And I add "Content-Type" header equal to "application/ld+json"
218+
And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg=="
219+
And I send a "GET" request to "/secured_dummies/40000/to_from"
220+
Then the response status code should be 404
221+
222+
@link_security
223+
Scenario: An user can get related linked dummies for an secured dummy they own
224+
Given there are 1 SecuredDummy objects owned by dunglas with related dummies
225+
When I add "Accept" header equal to "application/ld+json"
226+
And I add "Content-Type" header equal to "application/ld+json"
227+
And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg=="
228+
And I send a "GET" request to "/secured_dummies/4/to_from"
229+
Then the response status code should be 200
230+
And the response should contain "securedDummy"
231+
And the JSON node "hydra:member[0].id" should be equal to 1
232+
233+
@link_security
234+
Scenario: I define a custom name of the security object
235+
When I add "Accept" header equal to "application/ld+json"
236+
And I add "Content-Type" header equal to "application/ld+json"
237+
And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg=="
238+
And I send a "GET" request to "/secured_dummies/4/with_name"
239+
Then the response status code should be 200
240+
And the response should contain "securedDummy"
241+
And the JSON node "hydra:member[0].id" should be equal to 1
242+
243+
@link_security
244+
Scenario: I define a from from link
245+
When I add "Accept" header equal to "application/ld+json"
246+
And I add "Content-Type" header equal to "application/ld+json"
247+
And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg=="
248+
And I send a "GET" request to "/related_linked_dummies/1/from_from"
249+
Then the response status code should be 200
250+
And the response should contain "id"
251+
And the JSON node "hydra:member[0].id" should be equal to 4
252+
253+
@link_security
254+
Scenario: I define multiple links with security
255+
When I add "Accept" header equal to "application/ld+json"
256+
And I add "Content-Type" header equal to "application/ld+json"
257+
And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg=="
258+
And I send a "GET" request to "/secured_dummies/4/related/1"
259+
Then the response status code should be 200
260+
And the response should contain "id"
261+
And the JSON node "hydra:member[0].id" should be equal to 1
262+
263+
@link_security
264+
Scenario: An user can not get related linked dummies for an secured dummy they do not own
265+
Given there are 1 SecuredDummy objects owned by someone with related dummies
266+
When I add "Accept" header equal to "application/ld+json"
267+
And I add "Content-Type" header equal to "application/ld+json"
268+
And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg=="
269+
And I send a "GET" request to "/secured_dummies/5/to_from"
270+
Then the response status code should be 403
271+
272+
@link_security
273+
Scenario: I define a custom name of the security object
274+
When I add "Accept" header equal to "application/ld+json"
275+
And I add "Content-Type" header equal to "application/ld+json"
276+
And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg=="
277+
And I send a "GET" request to "/secured_dummies/5/with_name"
278+
Then the response status code should be 403
279+
280+
@link_security
281+
Scenario: I define a from from link
282+
When I add "Accept" header equal to "application/ld+json"
283+
And I add "Content-Type" header equal to "application/ld+json"
284+
And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg=="
285+
And I send a "GET" request to "/related_linked_dummies/2/from_from"
286+
Then the response status code should be 403
287+
288+
@link_security
289+
Scenario: I define multiple links with security
290+
When I add "Accept" header equal to "application/ld+json"
291+
And I add "Content-Type" header equal to "application/ld+json"
292+
And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg=="
293+
And I send a "GET" request to "/secured_dummies/5/related/2"
294+
Then the response status code should be 403
295+
214296
Scenario: A user retrieves a resource with an admin only viewable property
215297
When I add "Accept" header equal to "application/json"
216298
And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg=="

src/Metadata/Link.php

+52-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::TARGET_PARAMETER)]
1717
final class Link
1818
{
19-
public function __construct(private ?string $parameterName = null, private ?string $fromProperty = null, private ?string $toProperty = null, private ?string $fromClass = null, private ?string $toClass = null, private ?array $identifiers = null, private ?bool $compositeIdentifier = null, private ?string $expandedValue = null)
19+
public function __construct(private ?string $parameterName = null, private ?string $fromProperty = null, private ?string $toProperty = null, private ?string $fromClass = null, private ?string $toClass = null, private ?array $identifiers = null, private ?bool $compositeIdentifier = null, private ?string $expandedValue = null, private ?string $security = null, private ?string $securityMessage = null, private ?string $securityObjectName = null)
2020
{
2121
// For the inverse property shortcut
2222
if ($this->parameterName && class_exists($this->parameterName)) {
@@ -128,6 +128,45 @@ public function withExpandedValue(string $expandedValue): self
128128
return $self;
129129
}
130130

131+
public function getSecurity(): ?string
132+
{
133+
return $this->security;
134+
}
135+
136+
public function getSecurityMessage(): ?string
137+
{
138+
return $this->securityMessage;
139+
}
140+
141+
public function withSecurity(?string $security): self
142+
{
143+
$self = clone $this;
144+
$self->security = $security;
145+
146+
return $self;
147+
}
148+
149+
public function withSecurityMessage(?string $securityMessage): self
150+
{
151+
$self = clone $this;
152+
$self->securityMessage = $securityMessage;
153+
154+
return $self;
155+
}
156+
157+
public function getSecurityObjectName(): ?string
158+
{
159+
return $this->securityObjectName;
160+
}
161+
162+
public function withSecurityObjectName(?string $securityObjectName): self
163+
{
164+
$self = clone $this;
165+
$self->securityObjectName = $securityObjectName;
166+
167+
return $self;
168+
}
169+
131170
public function withLink(self $link): self
132171
{
133172
$self = clone $this;
@@ -164,6 +203,18 @@ public function withLink(self $link): self
164203
$self->expandedValue = $expandedValue;
165204
}
166205

206+
if (!$self->getSecurity() && ($security = $link->getSecurity())) {
207+
$self->security = $security;
208+
}
209+
210+
if (!$self->getSecurityMessage() && ($securityMessage = $link->getSecurityMessage())) {
211+
$self->securityMessage = $securityMessage;
212+
}
213+
214+
if (!$self->getSecurityObjectName() && ($securityObjectName = $link->getSecurityObjectName())) {
215+
$self->securityObjectName = $securityObjectName;
216+
}
217+
167218
return $self;
168219
}
169220
}

src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php

+8
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ public function load(array $configs, ContainerBuilder $container): void
164164
$this->registerSecurityConfiguration($container, $config, $loader);
165165
$this->registerMakerConfiguration($container, $config, $loader);
166166
$this->registerArgumentResolverConfiguration($loader);
167+
$this->registerLinkSecurityConfiguration($loader, $config);
167168

168169
$container->registerForAutoconfiguration(FilterInterface::class)
169170
->addTag('api_platform.filter');
@@ -892,4 +893,11 @@ private function registerInflectorConfiguration(array $config): void
892893
Inflector::keepLegacyInflector(false);
893894
}
894895
}
896+
897+
private function registerLinkSecurityConfiguration(XmlFileLoader $loader, array $config): void
898+
{
899+
if ($config['enable_link_security']) {
900+
$loader->load('link_security.xml');
901+
}
902+
}
895903
}

src/Symfony/Bundle/DependencyInjection/Configuration.php

+1
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ public function getConfigTreeBuilder(): TreeBuilder
111111
->booleanNode('enable_docs')->defaultTrue()->info('Enable the docs')->end()
112112
->booleanNode('enable_profiler')->defaultTrue()->info('Enable the data collector and the WebProfilerBundle integration.')->end()
113113
->booleanNode('keep_legacy_inflector')->defaultTrue()->info('Keep doctrine/inflector instead of symfony/string to generate plurals for routes.')->end()
114+
->booleanNode('enable_link_security')->defaultFalse()->info('Enable security for Links (sub resources)')->end()
114115
->arrayNode('collection')
115116
->addDefaultsIfNotSet()
116117
->children()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?xml version="1.0" ?>
2+
3+
<container xmlns="http://symfony.com/schema/dic/services"
4+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
5+
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
6+
7+
<services>
8+
9+
<service id="api_platform.state_provider.read_link" class="ApiPlatform\Symfony\Security\State\LinkedReadProvider" decorates="api_platform.state_provider.read">
10+
<argument type="service" id="api_platform.state_provider.read_link.inner" />
11+
<argument type="service" id="api_platform.state_provider.locator" />
12+
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
13+
</service>
14+
15+
<service id="api_platform.state_provider.access_checker_linked" class="ApiPlatform\Symfony\Security\State\LinkAccessCheckerProvider" decorates="api_platform.state_provider.read_link">
16+
<argument type="service" id="api_platform.state_provider.access_checker_linked.inner" />
17+
<argument type="service" id="api_platform.security.resource_access_checker" />
18+
</service>
19+
</services>
20+
</container>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Symfony\Security\State;
15+
16+
use ApiPlatform\Metadata\HttpOperation;
17+
use ApiPlatform\Metadata\Link;
18+
use ApiPlatform\Metadata\Operation;
19+
use ApiPlatform\State\ProviderInterface;
20+
use ApiPlatform\Symfony\Security\Exception\AccessDeniedException;
21+
use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface;
22+
23+
/**
24+
* Checks the individual parts of the linked resource for access rights.
25+
*
26+
* @experimental
27+
*/
28+
final class LinkAccessCheckerProvider implements ProviderInterface
29+
{
30+
public function __construct(
31+
private readonly ProviderInterface $decorated,
32+
private readonly ResourceAccessCheckerInterface $resourceAccessChecker
33+
) {
34+
}
35+
36+
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
37+
{
38+
$request = ($context['request'] ?? null);
39+
40+
$data = $this->decorated->provide($operation, $uriVariables, $context);
41+
42+
if ($operation instanceof HttpOperation && $operation->getUriVariables()) {
43+
foreach ($operation->getUriVariables() as $uriVariable) {
44+
if (!$uriVariable instanceof Link || !$uriVariable->getSecurity()) {
45+
continue;
46+
}
47+
48+
$targetResource = $uriVariable->getFromClass() ?? $uriVariable->getToClass();
49+
50+
if (!$targetResource) {
51+
continue;
52+
}
53+
54+
$propertyName = $uriVariable->getToProperty() ?? $uriVariable->getFromProperty();
55+
$securityObjectName = $uriVariable->getSecurityObjectName();
56+
57+
if (!$securityObjectName) {
58+
$securityObjectName = $propertyName;
59+
}
60+
61+
if (!$securityObjectName) {
62+
continue;
63+
}
64+
65+
$resourceAccessCheckerContext = [
66+
'object' => $data,
67+
'previous_object' => $request?->attributes->get('previous_data'),
68+
$securityObjectName => $request?->attributes->get($securityObjectName),
69+
'request' => $request,
70+
];
71+
72+
if (!$this->resourceAccessChecker->isGranted($targetResource, $uriVariable->getSecurity(), $resourceAccessCheckerContext)) {
73+
throw new AccessDeniedException($uriVariable->getSecurityMessage() ?? 'Access Denied.');
74+
}
75+
}
76+
}
77+
78+
return $data;
79+
}
80+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Symfony\Security\State;
15+
16+
use ApiPlatform\Exception\InvalidIdentifierException;
17+
use ApiPlatform\Exception\InvalidUriVariableException;
18+
use ApiPlatform\Metadata\HttpOperation;
19+
use ApiPlatform\Metadata\Link;
20+
use ApiPlatform\Metadata\Operation;
21+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
22+
use ApiPlatform\State\Exception\ProviderNotFoundException;
23+
use ApiPlatform\State\ProviderInterface;
24+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
25+
26+
/**
27+
* Checks if the linked resources have security attributes and prepares them for access checking.
28+
*
29+
* @experimental
30+
*/
31+
final class LinkedReadProvider implements ProviderInterface
32+
{
33+
public function __construct(
34+
private readonly ProviderInterface $decorated,
35+
private readonly ProviderInterface $locator,
36+
private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory
37+
) {
38+
}
39+
40+
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
41+
{
42+
$data = $this->decorated->provide($operation, $uriVariables, $context);
43+
44+
if (!$operation instanceof HttpOperation) {
45+
return $data;
46+
}
47+
48+
$request = ($context['request'] ?? null);
49+
50+
if ($operation->getUriVariables()) {
51+
foreach ($operation->getUriVariables() as $key => $uriVariable) {
52+
if (!$uriVariable instanceof Link || !$uriVariable->getSecurity()) {
53+
continue;
54+
}
55+
56+
$relationClass = $uriVariable->getFromClass() ?? $uriVariable->getToClass();
57+
58+
if (!$relationClass) {
59+
continue;
60+
}
61+
62+
$parentOperation = $this->resourceMetadataCollectionFactory
63+
->create($relationClass)
64+
->getOperation($operation->getExtraProperties()['parent_uri_template'] ?? null);
65+
try {
66+
$relation = $this->locator->provide($parentOperation, [$uriVariable->getIdentifiers()[0] => $request?->attributes->all()[$key]], $context);
67+
} catch (ProviderNotFoundException) {
68+
$relation = null;
69+
}
70+
71+
if (!$relation) {
72+
throw new NotFoundHttpException('Relation for link security not found.');
73+
}
74+
75+
try {
76+
$securityObjectName = $uriVariable->getSecurityObjectName();
77+
78+
if (!$securityObjectName) {
79+
$securityObjectName = $uriVariable->getToProperty() ?? $uriVariable->getFromProperty();
80+
}
81+
82+
$request?->attributes->set($securityObjectName, $relation);
83+
} catch (InvalidIdentifierException|InvalidUriVariableException $e) {
84+
throw new NotFoundHttpException('Invalid identifier value or configuration.', $e);
85+
}
86+
}
87+
}
88+
89+
return $data;
90+
}
91+
}

0 commit comments

Comments
 (0)