Skip to content

Commit

Permalink
GraphQL: honor access control rules
Browse files Browse the repository at this point in the history
  • Loading branch information
dunglas committed Dec 22, 2017
1 parent 7113a05 commit 31edb9b
Show file tree
Hide file tree
Showing 14 changed files with 271 additions and 65 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 retrieves 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"}) {
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."
3 changes: 3 additions & 0 deletions src/Bridge/Symfony/Bundle/Resources/config/graphql.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<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>
Expand All @@ -24,12 +25,14 @@
<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">
<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">
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
18 changes: 14 additions & 4 deletions src/Graphql/Resolver/Factory/CollectionResolverFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use ApiPlatform\Core\Exception\ResourceClassNotSupportedException;
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 @@ -39,16 +40,18 @@ final class CollectionResolverFactory implements ResolverFactoryInterface
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 +75,16 @@ 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);

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
11 changes: 10 additions & 1 deletion src/Graphql/Resolver/Factory/ItemMutationResolverFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use ApiPlatform\Core\Exception\ItemNotFoundException;
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 @@ -37,8 +38,9 @@ final class ItemMutationResolverFactory implements ResolverFactoryInterface
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 +50,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 @@ -63,6 +66,12 @@ public function __invoke(string $resourceClass = null, string $rootClass = null,
}

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

switch ($operationName) {
case 'create':
Expand Down
18 changes: 16 additions & 2 deletions src/Graphql/Resolver/ItemResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
use ApiPlatform\Core\Exception\ItemNotFoundException;
use ApiPlatform\Core\Graphql\Serializer\ItemNormalizer;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Security\ResourceAccessCheckerInterface;
use ApiPlatform\Core\Util\ClassInfoTrait;
use GraphQL\Error\Error;
use GraphQL\Type\Definition\ResolveInfo;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

Expand All @@ -34,14 +36,16 @@ final class ItemResolver
use ClassInfoTrait;

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

public function __construct(IriConverterInterface $iriConverter, NormalizerInterface $normalizer, ResourceMetadataFactoryInterface $resourceMetadataFactory)
public function __construct(IriConverterInterface $iriConverter, NormalizerInterface $normalizer, ResourceMetadataFactoryInterface $resourceMetadataFactory, ResourceAccessCheckerInterface $resourceAccessChecker = null)
{
$this->iriConverter = $iriConverter;
$this->normalizer = $normalizer;
$this->resourceMetadataFactory = $resourceMetadataFactory;
$this->resourceAccessChecker = $resourceAccessChecker;
}

public function __invoke($source, $args, $context, ResolveInfo $info)
Expand All @@ -62,7 +66,17 @@ public function __invoke($source, $args, $context, ResolveInfo $info)
return null;
}

$normalizationContext = $this->resourceMetadataFactory->create($this->getObjectClass($item))->getGraphqlAttribute('query', 'normalization_context', [], true);
$resourceClass = $this->getObjectClass($item);
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);

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

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

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

0 comments on commit 31edb9b

Please sign in to comment.