Skip to content

Commit

Permalink
GraphQL: honor access control rules (#1602)
Browse files Browse the repository at this point in the history
  • Loading branch information
dunglas committed Dec 24, 2017
1 parent 414f297 commit 337ffdf
Show file tree
Hide file tree
Showing 30 changed files with 534 additions and 123 deletions.
7 changes: 3 additions & 4 deletions features/authorization/deny.feature
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
Feature: Authorization checking
In order to use the API
As a client software developer
As a client software user
I need to be authorized to access a given resource.

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


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

Scenario: An user retrieve cannot retrieve an item he doesn't own
Scenario: An user cannot retrieve an item he doesn't own
When I add "Accept" header equal to "application/ld+json"
And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg=="
And I send a "GET" request to "/secured_dummies/1"
Expand Down
18 changes: 18 additions & 0 deletions features/bootstrap/FeatureContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelationEmbedder;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\SecuredDummy;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\ThirdLevel;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\User;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\UuidIdentifierDummy;
Expand Down Expand Up @@ -555,6 +556,23 @@ public function thereIsDummyObjectsWithRelationEmbeddedDummyBoolean(int $nb, str
$this->manager->flush();
}

/**
* @Given there are :nb SecuredDummy objects
*/
public function thereAreSecuredDummyObjects(int $nb)
{
for ($i = 1; $i <= $nb; ++$i) {
$securedDummy = new SecuredDummy();
$securedDummy->setTitle("#$i");
$securedDummy->setDescription("Hello #$i");
$securedDummy->setOwner('notexist');

$this->manager->persist($securedDummy);
}

$this->manager->flush();
}

/**
* @Given there is a RelationEmbedder object
*/
Expand Down
57 changes: 57 additions & 0 deletions features/graphql/authorization.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
Feature: Authorization checking
In order to use the GraphQL API
As a client software user
I need to be authorized to access a given resource.

@createSchema
Scenario: An anonymous user tries to retrieve a secured item
Given there are 1 SecuredDummy objects
When I send the following GraphQL request:
"""
{
securedDummy(id: "/secured_dummies/1") {
title
description
}
}
"""
Then the response status code should be 400
And the response should be in JSON
And the header "Content-Type" should be equal to "application/json"
And the JSON node "errors[0].message" should be equal to "Access Denied."

Scenario: An anonymous user tries to retrieve a secured collection
Given there are 1 SecuredDummy objects
When I send the following GraphQL request:
"""
{
securedDummies {
edges {
node {
title
description
}
}
}
}
"""
Then the response status code should be 400
And the response should be in JSON
And the header "Content-Type" should be equal to "application/json"
And the JSON node "errors[0].message" should be equal to "Access Denied."

@dropSchema
Scenario: An anonymous user tries to create a resource he is not allowed to
When I send the following GraphQL request:
"""
mutation {
createSecuredDummy(input: {owner: "me", title: "Hi", description: "Desc", clientMutationId: "auth"}) {
title
owner
}
}
"""
Then the response status code should be 400
And the response should be in JSON
And the header "Content-Type" should be equal to "application/json"
And the JSON node "errors[0].message" should be equal to "Access Denied."
19 changes: 11 additions & 8 deletions src/Bridge/Symfony/Bundle/Resources/config/graphql.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,38 +5,41 @@
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

<services>
<service id="api_platform.graphql.executor" class="ApiPlatform\Core\Graphql\Executor" public="false" />
<service id="api_platform.graphql.executor" class="ApiPlatform\Core\GraphQl\Executor" public="false" />

<!-- Resolvers -->

<service id="api_platform.graphql.resolver.factory.collection" class="ApiPlatform\Core\Graphql\Resolver\Factory\CollectionResolverFactory" public="false">
<service id="api_platform.graphql.resolver.factory.collection" class="ApiPlatform\Core\GraphQl\Resolver\Factory\CollectionResolverFactory" public="false">
<argument type="service" id="api_platform.collection_data_provider" />
<argument type="service" id="api_platform.subresource_data_provider" />
<argument type="service" id="serializer" />
<argument type="service" id="api_platform.identifiers_extractor.cached" />
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
<argument type="service" id="api_platform.security.resource_access_checker" on-invalid="null" />
<argument type="service" id="request_stack" />
<argument>%api_platform.collection.pagination.enabled%</argument>
</service>

<service id="api_platform.graphql.resolver.factory.item_mutation" class="ApiPlatform\Core\Graphql\Resolver\Factory\ItemMutationResolverFactory" public="false">
<service id="api_platform.graphql.resolver.factory.item_mutation" class="ApiPlatform\Core\GraphQl\Resolver\Factory\ItemMutationResolverFactory" public="false">
<argument type="service" id="api_platform.iri_converter" />
<argument type="service" id="api_platform.data_persister" />
<argument type="service" id="serializer" />
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
<argument type="service" id="api_platform.security.resource_access_checker" on-invalid="ignore" />
</service>

<service id="api_platform.graphql.resolver.item" class="ApiPlatform\Core\Graphql\Resolver\ItemResolver" public="false">
<service id="api_platform.graphql.resolver.item" class="ApiPlatform\Core\GraphQl\Resolver\ItemResolver" public="false">
<argument type="service" id="api_platform.iri_converter" />
<argument type="service" id="serializer" />
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
<argument type="service" id="api_platform.security.resource_access_checker" on-invalid="ignore" />
</service>

<service id="api_platform.graphql.resolver.resource_field" class="ApiPlatform\Core\Graphql\Resolver\ResourceFieldResolver" public="false">
<service id="api_platform.graphql.resolver.resource_field" class="ApiPlatform\Core\GraphQl\Resolver\ResourceFieldResolver" public="false">
<argument type="service" id="api_platform.iri_converter" />
</service>

<service id="api_platform.graphql.schema_builder" class="ApiPlatform\Core\Graphql\Type\SchemaBuilder" public="false">
<service id="api_platform.graphql.schema_builder" class="ApiPlatform\Core\GraphQl\Type\SchemaBuilder" public="false">
<argument type="service" id="api_platform.metadata.property.name_collection_factory" />
<argument type="service" id="api_platform.metadata.property.metadata_factory" />
<argument type="service" id="api_platform.metadata.resource.name_collection_factory" />
Expand All @@ -50,7 +53,7 @@

<!-- Action -->

<service id="api_platform.graphql.action.entrypoint" class="ApiPlatform\Core\Graphql\Action\EntrypointAction" public="true">
<service id="api_platform.graphql.action.entrypoint" class="ApiPlatform\Core\GraphQl\Action\EntrypointAction" public="true">
<argument type="service" id="api_platform.graphql.schema_builder" />
<argument type="service" id="api_platform.graphql.executor" />
<argument type="service" id="twig" />
Expand All @@ -61,7 +64,7 @@

<!-- Serializer -->

<service id="api_platform.graphql.normalizer.item" class="ApiPlatform\Core\Graphql\Serializer\ItemNormalizer" public="false">
<service id="api_platform.graphql.normalizer.item" class="ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer" public="false">
<argument type="service" id="api_platform.metadata.property.name_collection_factory" />
<argument type="service" id="api_platform.metadata.property.metadata_factory" />
<argument type="service" id="api_platform.iri_converter" />
Expand Down
11 changes: 8 additions & 3 deletions src/Bridge/Symfony/Bundle/Resources/config/security.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,20 @@
<services>
<service id="api_platform.security.expression_language" class="ApiPlatform\Core\Security\ExpressionLanguage" public="false" />

<!-- This listener must be executed only when the current object is available -->
<service id="api_platform.security.listener.request.deny_access" class="ApiPlatform\Core\Security\EventListener\DenyAccessListener">
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
<service id="api_platform.security.resource_access_checker" class="ApiPlatform\Core\Security\ResourceAccessChecker" public="false">
<argument type="service" id="api_platform.security.expression_language" on-invalid="null" />
<argument type="service" id="security.authentication.trust_resolver" on-invalid="null" />
<argument type="service" id="security.role_hierarchy" on-invalid="null" />
<argument type="service" id="security.token_storage" on-invalid="null" />
<argument type="service" id="security.authorization_checker" on-invalid="null" />
</service>
<service id="ApiPlatform\Core\Security\ResourceAccessCheckerInterface" alias="api_platform.security.resource_access_checker" />

<service id="api_platform.security.listener.request.deny_access" class="ApiPlatform\Core\Security\EventListener\DenyAccessListener">
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
<argument type="service" id="api_platform.security.resource_access_checker" />

<!-- This listener must be executed only when the current object is available -->
<tag name="kernel.event_listener" event="kernel.request" method="onKernelRequest" priority="1" />
</service>
</services>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@

declare(strict_types=1);

namespace ApiPlatform\Core\Graphql\Action;
namespace ApiPlatform\Core\GraphQl\Action;

use ApiPlatform\Core\Graphql\ExecutorInterface;
use ApiPlatform\Core\Graphql\Type\SchemaBuilderInterface;
use ApiPlatform\Core\GraphQl\ExecutorInterface;
use ApiPlatform\Core\GraphQl\Type\SchemaBuilderInterface;
use GraphQL\Error\Error;
use GraphQL\Executor\ExecutionResult;
use Symfony\Component\HttpFoundation\JsonResponse;
Expand Down
2 changes: 1 addition & 1 deletion src/Graphql/Executor.php → src/GraphQl/Executor.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

declare(strict_types=1);

namespace ApiPlatform\Core\Graphql;
namespace ApiPlatform\Core\GraphQl;

use GraphQL\Executor\ExecutionResult;
use GraphQL\GraphQL;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

declare(strict_types=1);

namespace ApiPlatform\Core\Graphql;
namespace ApiPlatform\Core\GraphQl;

use GraphQL\Executor\ExecutionResult;
use GraphQL\Type\Schema;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@

declare(strict_types=1);

namespace ApiPlatform\Core\Graphql\Resolver\Factory;
namespace ApiPlatform\Core\GraphQl\Resolver\Factory;

use ApiPlatform\Core\Api\IdentifiersExtractorInterface;
use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface;
use ApiPlatform\Core\DataProvider\PaginatorInterface;
use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface;
use ApiPlatform\Core\Exception\ResourceClassNotSupportedException;
use ApiPlatform\Core\Graphql\Serializer\ItemNormalizer;
use ApiPlatform\Core\GraphQl\Resolver\ResourceAccessCheckerTrait;
use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Security\ResourceAccessCheckerInterface;
use GraphQL\Error\Error;
use GraphQL\Type\Definition\ResolveInfo;
use Symfony\Component\HttpFoundation\RequestStack;
Expand All @@ -35,20 +37,24 @@
*/
final class CollectionResolverFactory implements ResolverFactoryInterface
{
use ResourceAccessCheckerTrait;

private $collectionDataProvider;
private $subresourceDataProvider;
private $normalizer;
private $identifiersExtractor;
private $resourceAccessChecker;
private $requestStack;
private $paginationEnabled;
private $resourceMetadataFactory;

public function __construct(CollectionDataProviderInterface $collectionDataProvider, SubresourceDataProviderInterface $subresourceDataProvider, NormalizerInterface $normalizer, IdentifiersExtractorInterface $identifiersExtractor, ResourceMetadataFactoryInterface $resourceMetadataFactory, RequestStack $requestStack = null, bool $paginationEnabled = false)
public function __construct(CollectionDataProviderInterface $collectionDataProvider, SubresourceDataProviderInterface $subresourceDataProvider, NormalizerInterface $normalizer, IdentifiersExtractorInterface $identifiersExtractor, ResourceMetadataFactoryInterface $resourceMetadataFactory, ResourceAccessCheckerInterface $resourceAccessChecker = null, RequestStack $requestStack = null, bool $paginationEnabled = false)
{
$this->subresourceDataProvider = $subresourceDataProvider;
$this->collectionDataProvider = $collectionDataProvider;
$this->normalizer = $normalizer;
$this->identifiersExtractor = $identifiersExtractor;
$this->resourceAccessChecker = $resourceAccessChecker;
$this->requestStack = $requestStack;
$this->paginationEnabled = $paginationEnabled;
$this->resourceMetadataFactory = $resourceMetadataFactory;
Expand All @@ -72,9 +78,17 @@ public function __invoke(string $resourceClass = null, string $rootClass = null,
$collection = $this->collectionDataProvider->getCollection($resourceClass);
}

$normalizationContext = $this->resourceMetadataFactory
->create($resourceClass)
->getGraphqlAttribute('query', 'normalization_context', [], true);
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
$this->canAccess($this->resourceAccessChecker, $resourceMetadata, $resourceClass, $info, $collection);

if (null !== $this->resourceAccessChecker) {
$isGranted = $resourceMetadata->getGraphqlAttribute('query', 'access_control', null, true);
if (null !== $isGranted && !$this->resourceAccessChecker->isGranted($resourceClass, $isGranted, ['object' => $collection])) {
throw Error::createLocatedError('Access Denied.', $info->fieldNodes, $info->path);
}
}

$normalizationContext = $resourceMetadata->getGraphqlAttribute('query', 'normalization_context', [], true);
$normalizationContext['attributes'] = $this->fieldsToAttributes($info);

if (!$this->paginationEnabled) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@

declare(strict_types=1);

namespace ApiPlatform\Core\Graphql\Resolver\Factory;
namespace ApiPlatform\Core\GraphQl\Resolver\Factory;

use ApiPlatform\Core\Api\IriConverterInterface;
use ApiPlatform\Core\DataPersister\DataPersisterInterface;
use ApiPlatform\Core\Exception\InvalidArgumentException;
use ApiPlatform\Core\Exception\ItemNotFoundException;
use ApiPlatform\Core\Graphql\Serializer\ItemNormalizer;
use ApiPlatform\Core\GraphQl\Resolver\ResourceAccessCheckerTrait;
use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Security\ResourceAccessCheckerInterface;
use GraphQL\Error\Error;
use GraphQL\Type\Definition\ResolveInfo;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
Expand All @@ -33,12 +35,15 @@
*/
final class ItemMutationResolverFactory implements ResolverFactoryInterface
{
use ResourceAccessCheckerTrait;

private $iriConverter;
private $dataPersister;
private $normalizer;
private $resourceMetadataFactory;
private $resourceAccessChecker;

public function __construct(IriConverterInterface $iriConverter, DataPersisterInterface $dataPersister, NormalizerInterface $normalizer, ResourceMetadataFactoryInterface $resourceMetadataFactory)
public function __construct(IriConverterInterface $iriConverter, DataPersisterInterface $dataPersister, NormalizerInterface $normalizer, ResourceMetadataFactoryInterface $resourceMetadataFactory, ResourceAccessCheckerInterface $resourceAccessChecker = null)
{
if (!$normalizer instanceof DenormalizerInterface) {
throw new InvalidArgumentException(sprintf('The normalizer must implements the "%s" interface', DenormalizerInterface::class));
Expand All @@ -48,6 +53,7 @@ public function __construct(IriConverterInterface $iriConverter, DataPersisterIn
$this->dataPersister = $dataPersister;
$this->normalizer = $normalizer;
$this->resourceMetadataFactory = $resourceMetadataFactory;
$this->resourceAccessChecker = $resourceAccessChecker;
}

public function __invoke(string $resourceClass = null, string $rootClass = null, string $operationName = null): callable
Expand All @@ -65,6 +71,7 @@ public function __invoke(string $resourceClass = null, string $rootClass = null,
}

$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
$this->canAccess($this->resourceAccessChecker, $resourceMetadata, $resourceClass, $info, $item);

switch ($operationName) {
case 'create':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

declare(strict_types=1);

namespace ApiPlatform\Core\Graphql\Resolver\Factory;
namespace ApiPlatform\Core\GraphQl\Resolver\Factory;

/**
* Builds a GraphQL resolver.
Expand Down

0 comments on commit 337ffdf

Please sign in to comment.