Skip to content

Commit 1b12bf8

Browse files
committed
GraphQL: honor access control rules
1 parent 414f297 commit 1b12bf8

File tree

14 files changed

+271
-65
lines changed

14 files changed

+271
-65
lines changed

features/authorization/deny.feature

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
Feature: Authorization checking
22
In order to use the API
3-
As a client software developer
3+
As a client software user
44
I need to be authorized to access a given resource.
55

66
@createSchema
7-
Scenario: An anonymous user retrieve a secured resource
7+
Scenario: An anonymous user retrieves a secured resource
88
When I add "Accept" header equal to "application/ld+json"
99
And I send a "GET" request to "/secured_dummies"
1010
Then the response status code should be 401
@@ -16,7 +16,6 @@ Feature: Authorization checking
1616
Then the response status code should be 200
1717
And the response should be in JSON
1818

19-
2019
Scenario: A standard user cannot create a secured resource
2120
When I add "Accept" header equal to "application/ld+json"
2221
And I add "Content-Type" header equal to "application/ld+json"
@@ -59,7 +58,7 @@ Feature: Authorization checking
5958
"""
6059
Then the response status code should be 201
6160

62-
Scenario: An user retrieve cannot retrieve an item he doesn't own
61+
Scenario: An user retrieves cannot retrieve an item he doesn't own
6362
When I add "Accept" header equal to "application/ld+json"
6463
And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg=="
6564
And I send a "GET" request to "/secured_dummies/1"

features/bootstrap/FeatureContext.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy;
4141
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend;
4242
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelationEmbedder;
43+
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\SecuredDummy;
4344
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\ThirdLevel;
4445
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\User;
4546
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\UuidIdentifierDummy;
@@ -555,6 +556,23 @@ public function thereIsDummyObjectsWithRelationEmbeddedDummyBoolean(int $nb, str
555556
$this->manager->flush();
556557
}
557558

559+
/**
560+
* @Given there are :nb SecuredDummy objects
561+
*/
562+
public function thereAreSecuredDummyObjects(int $nb)
563+
{
564+
for ($i = 1; $i <= $nb; ++$i) {
565+
$securedDummy = new SecuredDummy();
566+
$securedDummy->setTitle("#$i");
567+
$securedDummy->setDescription("Hello #$i");
568+
$securedDummy->setOwner('notexist');
569+
570+
$this->manager->persist($securedDummy);
571+
}
572+
573+
$this->manager->flush();
574+
}
575+
558576
/**
559577
* @Given there is a RelationEmbedder object
560578
*/
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
Feature: Authorization checking
2+
In order to use the GraphQL API
3+
As a client software user
4+
I need to be authorized to access a given resource.
5+
6+
@createSchema
7+
Scenario: An anonymous user tries to retrieve a secured item
8+
Given there are 1 SecuredDummy objects
9+
When I send the following GraphQL request:
10+
"""
11+
{
12+
securedDummy(id: "/secured_dummies/1") {
13+
title
14+
description
15+
}
16+
}
17+
"""
18+
Then the response status code should be 400
19+
And the response should be in JSON
20+
And the header "Content-Type" should be equal to "application/json"
21+
And the JSON node "errors[0].message" should be equal to "Access Denied."
22+
23+
Scenario: An anonymous user tries to retrieve a secured collection
24+
Given there are 1 SecuredDummy objects
25+
When I send the following GraphQL request:
26+
"""
27+
{
28+
securedDummies {
29+
edges {
30+
node {
31+
title
32+
description
33+
}
34+
}
35+
}
36+
}
37+
"""
38+
Then the response status code should be 400
39+
And the response should be in JSON
40+
And the header "Content-Type" should be equal to "application/json"
41+
And the JSON node "errors[0].message" should be equal to "Access Denied."
42+
43+
@dropSchema
44+
Scenario: An anonymous user tries to create a resource he is not allowed to
45+
When I send the following GraphQL request:
46+
"""
47+
mutation {
48+
createSecuredDummy(input: {owner: "me", title: "Hi", description: "Desc"}) {
49+
title
50+
owner
51+
}
52+
}
53+
"""
54+
Then the response status code should be 400
55+
And the response should be in JSON
56+
And the header "Content-Type" should be equal to "application/json"
57+
And the JSON node "errors[0].message" should be equal to "Access Denied."

src/Bridge/Symfony/Bundle/Resources/config/graphql.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
<argument type="service" id="serializer" />
1616
<argument type="service" id="api_platform.identifiers_extractor.cached" />
1717
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
18+
<argument type="service" id="api_platform.security.resource_access_checker" on-invalid="null" />
1819
<argument type="service" id="request_stack" />
1920
<argument>%api_platform.collection.pagination.enabled%</argument>
2021
</service>
@@ -24,12 +25,14 @@
2425
<argument type="service" id="api_platform.data_persister" />
2526
<argument type="service" id="serializer" />
2627
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
28+
<argument type="service" id="api_platform.security.resource_access_checker" on-invalid="ignore" />
2729
</service>
2830

2931
<service id="api_platform.graphql.resolver.item" class="ApiPlatform\Core\Graphql\Resolver\ItemResolver" public="false">
3032
<argument type="service" id="api_platform.iri_converter" />
3133
<argument type="service" id="serializer" />
3234
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
35+
<argument type="service" id="api_platform.security.resource_access_checker" on-invalid="ignore" />
3336
</service>
3437

3538
<service id="api_platform.graphql.resolver.resource_field" class="ApiPlatform\Core\Graphql\Resolver\ResourceFieldResolver" public="false">

src/Bridge/Symfony/Bundle/Resources/config/security.xml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,20 @@
77
<services>
88
<service id="api_platform.security.expression_language" class="ApiPlatform\Core\Security\ExpressionLanguage" public="false" />
99

10-
<!-- This listener must be executed only when the current object is available -->
11-
<service id="api_platform.security.listener.request.deny_access" class="ApiPlatform\Core\Security\EventListener\DenyAccessListener">
12-
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
10+
<service id="api_platform.security.resource_access_checker" class="ApiPlatform\Core\Security\ResourceAccessChecker" public="false">
1311
<argument type="service" id="api_platform.security.expression_language" on-invalid="null" />
1412
<argument type="service" id="security.authentication.trust_resolver" on-invalid="null" />
1513
<argument type="service" id="security.role_hierarchy" on-invalid="null" />
1614
<argument type="service" id="security.token_storage" on-invalid="null" />
1715
<argument type="service" id="security.authorization_checker" on-invalid="null" />
16+
</service>
17+
<service id="ApiPlatform\Core\Security\ResourceAccessCheckerInterface" alias="api_platform.security.resource_access_checker" />
18+
19+
<service id="api_platform.security.listener.request.deny_access" class="ApiPlatform\Core\Security\EventListener\DenyAccessListener">
20+
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
21+
<argument type="service" id="api_platform.security.resource_access_checker" />
1822

23+
<!-- This listener must be executed only when the current object is available -->
1924
<tag name="kernel.event_listener" event="kernel.request" method="onKernelRequest" priority="1" />
2025
</service>
2126
</services>

src/Graphql/Resolver/Factory/CollectionResolverFactory.php

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use ApiPlatform\Core\Exception\ResourceClassNotSupportedException;
2121
use ApiPlatform\Core\Graphql\Serializer\ItemNormalizer;
2222
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
23+
use ApiPlatform\Core\Security\ResourceAccessCheckerInterface;
2324
use GraphQL\Error\Error;
2425
use GraphQL\Type\Definition\ResolveInfo;
2526
use Symfony\Component\HttpFoundation\RequestStack;
@@ -39,16 +40,18 @@ final class CollectionResolverFactory implements ResolverFactoryInterface
3940
private $subresourceDataProvider;
4041
private $normalizer;
4142
private $identifiersExtractor;
43+
private $resourceAccessChecker;
4244
private $requestStack;
4345
private $paginationEnabled;
4446
private $resourceMetadataFactory;
4547

46-
public function __construct(CollectionDataProviderInterface $collectionDataProvider, SubresourceDataProviderInterface $subresourceDataProvider, NormalizerInterface $normalizer, IdentifiersExtractorInterface $identifiersExtractor, ResourceMetadataFactoryInterface $resourceMetadataFactory, RequestStack $requestStack = null, bool $paginationEnabled = false)
48+
public function __construct(CollectionDataProviderInterface $collectionDataProvider, SubresourceDataProviderInterface $subresourceDataProvider, NormalizerInterface $normalizer, IdentifiersExtractorInterface $identifiersExtractor, ResourceMetadataFactoryInterface $resourceMetadataFactory, ResourceAccessCheckerInterface $resourceAccessChecker = null, RequestStack $requestStack = null, bool $paginationEnabled = false)
4749
{
4850
$this->subresourceDataProvider = $subresourceDataProvider;
4951
$this->collectionDataProvider = $collectionDataProvider;
5052
$this->normalizer = $normalizer;
5153
$this->identifiersExtractor = $identifiersExtractor;
54+
$this->resourceAccessChecker = $resourceAccessChecker;
5255
$this->requestStack = $requestStack;
5356
$this->paginationEnabled = $paginationEnabled;
5457
$this->resourceMetadataFactory = $resourceMetadataFactory;
@@ -72,9 +75,16 @@ public function __invoke(string $resourceClass = null, string $rootClass = null,
7275
$collection = $this->collectionDataProvider->getCollection($resourceClass);
7376
}
7477

75-
$normalizationContext = $this->resourceMetadataFactory
76-
->create($resourceClass)
77-
->getGraphqlAttribute('query', 'normalization_context', [], true);
78+
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
79+
80+
if (null !== $this->resourceAccessChecker) {
81+
$isGranted = $resourceMetadata->getGraphqlAttribute('query', 'access_control', null, true);
82+
if (null !== $isGranted && !$this->resourceAccessChecker->isGranted($resourceClass, $isGranted, ['object' => $collection])) {
83+
throw Error::createLocatedError('Access Denied.', $info->fieldNodes, $info->path);
84+
}
85+
}
86+
87+
$normalizationContext = $resourceMetadata->getGraphqlAttribute('query', 'normalization_context', [], true);
7888
$normalizationContext['attributes'] = $this->fieldsToAttributes($info);
7989

8090
if (!$this->paginationEnabled) {

src/Graphql/Resolver/Factory/ItemMutationResolverFactory.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use ApiPlatform\Core\Exception\ItemNotFoundException;
2020
use ApiPlatform\Core\Graphql\Serializer\ItemNormalizer;
2121
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
22+
use ApiPlatform\Core\Security\ResourceAccessCheckerInterface;
2223
use GraphQL\Error\Error;
2324
use GraphQL\Type\Definition\ResolveInfo;
2425
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
@@ -37,8 +38,9 @@ final class ItemMutationResolverFactory implements ResolverFactoryInterface
3738
private $dataPersister;
3839
private $normalizer;
3940
private $resourceMetadataFactory;
41+
private $resourceAccessChecker;
4042

41-
public function __construct(IriConverterInterface $iriConverter, DataPersisterInterface $dataPersister, NormalizerInterface $normalizer, ResourceMetadataFactoryInterface $resourceMetadataFactory)
43+
public function __construct(IriConverterInterface $iriConverter, DataPersisterInterface $dataPersister, NormalizerInterface $normalizer, ResourceMetadataFactoryInterface $resourceMetadataFactory, ResourceAccessCheckerInterface $resourceAccessChecker = null)
4244
{
4345
if (!$normalizer instanceof DenormalizerInterface) {
4446
throw new InvalidArgumentException(sprintf('The normalizer must implements the "%s" interface', DenormalizerInterface::class));
@@ -48,6 +50,7 @@ public function __construct(IriConverterInterface $iriConverter, DataPersisterIn
4850
$this->dataPersister = $dataPersister;
4951
$this->normalizer = $normalizer;
5052
$this->resourceMetadataFactory = $resourceMetadataFactory;
53+
$this->resourceAccessChecker = $resourceAccessChecker;
5154
}
5255

5356
public function __invoke(string $resourceClass = null, string $rootClass = null, string $operationName = null): callable
@@ -65,6 +68,12 @@ public function __invoke(string $resourceClass = null, string $rootClass = null,
6568
}
6669

6770
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
71+
if (null !== $this->resourceAccessChecker) {
72+
$isGranted = $resourceMetadata->getGraphqlAttribute('query', 'access_control', null, true);
73+
if (null !== $isGranted && !$this->resourceAccessChecker->isGranted($resourceClass, $isGranted, ['object' => $item])) {
74+
throw Error::createLocatedError('Access Denied.', $info->fieldNodes, $info->path);
75+
}
76+
}
6877

6978
switch ($operationName) {
7079
case 'create':

src/Graphql/Resolver/ItemResolver.php

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
use ApiPlatform\Core\Exception\ItemNotFoundException;
1818
use ApiPlatform\Core\Graphql\Serializer\ItemNormalizer;
1919
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
20+
use ApiPlatform\Core\Security\ResourceAccessCheckerInterface;
2021
use ApiPlatform\Core\Util\ClassInfoTrait;
22+
use GraphQL\Error\Error;
2123
use GraphQL\Type\Definition\ResolveInfo;
2224
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
2325

@@ -34,14 +36,16 @@ final class ItemResolver
3436
use ClassInfoTrait;
3537

3638
private $iriConverter;
39+
private $resourceAccessChecker;
3740
private $normalizer;
3841
private $resourceMetadataFactory;
3942

40-
public function __construct(IriConverterInterface $iriConverter, NormalizerInterface $normalizer, ResourceMetadataFactoryInterface $resourceMetadataFactory)
43+
public function __construct(IriConverterInterface $iriConverter, NormalizerInterface $normalizer, ResourceMetadataFactoryInterface $resourceMetadataFactory, ResourceAccessCheckerInterface $resourceAccessChecker = null)
4144
{
4245
$this->iriConverter = $iriConverter;
4346
$this->normalizer = $normalizer;
4447
$this->resourceMetadataFactory = $resourceMetadataFactory;
48+
$this->resourceAccessChecker = $resourceAccessChecker;
4549
}
4650

4751
public function __invoke($source, $args, $context, ResolveInfo $info)
@@ -62,7 +66,17 @@ public function __invoke($source, $args, $context, ResolveInfo $info)
6266
return null;
6367
}
6468

65-
$normalizationContext = $this->resourceMetadataFactory->create($this->getObjectClass($item))->getGraphqlAttribute('query', 'normalization_context', [], true);
69+
$resourceClass = $this->getObjectClass($item);
70+
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
71+
72+
if (null !== $this->resourceAccessChecker) {
73+
$isGranted = $resourceMetadata->getGraphqlAttribute('query', 'access_control', null, true);
74+
if (null !== $isGranted && !$this->resourceAccessChecker->isGranted($resourceClass, $isGranted, ['object' => $item])) {
75+
throw Error::createLocatedError('Access Denied.', $info->fieldNodes, $info->path);
76+
}
77+
}
78+
79+
$normalizationContext = $resourceMetadata->getGraphqlAttribute('query', 'normalization_context', [], true);
6680

6781
return $this->normalizer->normalize($item, ItemNormalizer::FORMAT, $normalizationContext + ['attributes' => $info->getFieldSelection(PHP_INT_MAX)]);
6882
}

0 commit comments

Comments
 (0)