Skip to content

Commit 8a799bb

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 16f14c836e19635888e5430a7dff9ecf8280c1d3. * refactor: Refactor ReadProvider.php and AccessCheckerProvider.php to extract link security into their own providers * mark providers final, disable feature by default
1 parent ea5be4b commit 8a799bb

File tree

5 files changed

+200
-0
lines changed

5 files changed

+200
-0
lines changed

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
}

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()
+20
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+
}

Security/State/LinkedReadProvider.php

+91
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)