From cba3acfbd517763cf320167250c5bed6d569696a Mon Sep 17 00:00:00 2001 From: Antoine Bluchet <soyuka@users.noreply.github.com> Date: Thu, 3 Apr 2025 17:01:56 +0200 Subject: [PATCH 1/5] fix(graphql): property security might be cached w/ different objects --- src/GraphQl/Serializer/ItemNormalizer.php | 2 + .../Document/SecuredDummyCollection.php | 60 ++++++ .../Document/SecuredDummyCollectionParent.php | 45 +++++ .../Entity/SecuredDummyCollection.php | 62 ++++++ .../Entity/SecuredDummyCollectionParent.php | 46 +++++ tests/Functional/GraphQl/SecurityTest.php | 188 ++++++++++++++++++ 6 files changed, 403 insertions(+) create mode 100644 tests/Fixtures/TestBundle/Document/SecuredDummyCollection.php create mode 100644 tests/Fixtures/TestBundle/Document/SecuredDummyCollectionParent.php create mode 100644 tests/Fixtures/TestBundle/Entity/SecuredDummyCollection.php create mode 100644 tests/Fixtures/TestBundle/Entity/SecuredDummyCollectionParent.php create mode 100644 tests/Functional/GraphQl/SecurityTest.php diff --git a/src/GraphQl/Serializer/ItemNormalizer.php b/src/GraphQl/Serializer/ItemNormalizer.php index d0c7c384079..06ea9df299a 100644 --- a/src/GraphQl/Serializer/ItemNormalizer.php +++ b/src/GraphQl/Serializer/ItemNormalizer.php @@ -89,6 +89,8 @@ public function normalize(mixed $object, ?string $format = null, array $context if ($this->isCacheKeySafe($context)) { $context['cache_key'] = $this->getCacheKey($format, $context); + } else { + $context['cache_key'] = false; } unset($context['operation_name'], $context['operation']); // Remove operation and operation_name only when cache key has been created diff --git a/tests/Fixtures/TestBundle/Document/SecuredDummyCollection.php b/tests/Fixtures/TestBundle/Document/SecuredDummyCollection.php new file mode 100644 index 00000000000..9f46e84e0b4 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/SecuredDummyCollection.php @@ -0,0 +1,60 @@ +<?php + +/* + * This file is part of the API Platform project. + * + * (c) Kévin Dunglas <dunglas@gmail.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\NotExposed; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Secured resource. + */ +#[ApiResource( + operations: [ + new NotExposed(), + ], + graphQlOperations: [ + new Query(), + new QueryCollection(), + ], + security: 'is_granted(\'ROLE_USER\')' +)] +#[ODM\Document] +class SecuredDummyCollection +{ + #[ODM\Id(strategy: 'AUTO', type: 'integer')] + public ?int $id = null; + + /** + * @var string The title + */ + #[ODM\Field] + public string $title; + + /** + * @var string Secret property, only readable/writable by owners + */ + #[ApiProperty(security: 'object == null or object.owner == user', securityPostDenormalize: 'object.owner == user')] + #[ODM\Field] + public ?string $ownerOnlyProperty = null; + + /** + * @var string The owner + */ + #[ODM\Field] + public string $owner; +} diff --git a/tests/Fixtures/TestBundle/Document/SecuredDummyCollectionParent.php b/tests/Fixtures/TestBundle/Document/SecuredDummyCollectionParent.php new file mode 100644 index 00000000000..ee1843be412 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/SecuredDummyCollectionParent.php @@ -0,0 +1,45 @@ +<?php + +/* + * This file is part of the API Platform project. + * + * (c) Kévin Dunglas <dunglas@gmail.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\NotExposed; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Secured resource. + */ +#[ApiResource( + operations: [ + new NotExposed(), + ], + graphQlOperations: [ + new Query(), + new QueryCollection(), + ], + security: 'is_granted(\'ROLE_USER\')' +)] +#[ODM\Document] +class SecuredDummyCollectionParent +{ + #[ODM\Id] + #[ODM\Field(type: 'id')] + public ?string $id = null; + + #[ODM\ReferenceOne(targetDocument: SecuredDummyCollection::class, inversedBy: 'parents')] + #[ODM\Field(nullable: false)] + public SecuredDummyCollection $child; +} diff --git a/tests/Fixtures/TestBundle/Entity/SecuredDummyCollection.php b/tests/Fixtures/TestBundle/Entity/SecuredDummyCollection.php new file mode 100644 index 00000000000..03abb4a3000 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/SecuredDummyCollection.php @@ -0,0 +1,62 @@ +<?php + +/* + * This file is part of the API Platform project. + * + * (c) Kévin Dunglas <dunglas@gmail.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\NotExposed; +use Doctrine\ORM\Mapping as ORM; + +/** + * Secured resource. + */ +#[ApiResource( + operations: [ + new NotExposed(), + ], + graphQlOperations: [ + new Query(), + new QueryCollection(), + ], + security: 'is_granted(\'ROLE_USER\')' +)] +#[ORM\Entity] +class SecuredDummyCollection +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null; + + /** + * @var string The title + */ + #[ORM\Column] + public string $title; + + /** + * @var string Secret property, only readable/writable by owners + */ + #[ApiProperty(security: 'object == null or object.owner == user', securityPostDenormalize: 'object.owner == user')] + #[ORM\Column] + public ?string $ownerOnlyProperty = null; + + /** + * @var string The owner + */ + #[ORM\Column] + public string $owner; +} diff --git a/tests/Fixtures/TestBundle/Entity/SecuredDummyCollectionParent.php b/tests/Fixtures/TestBundle/Entity/SecuredDummyCollectionParent.php new file mode 100644 index 00000000000..ad053cfc456 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/SecuredDummyCollectionParent.php @@ -0,0 +1,46 @@ +<?php + +/* + * This file is part of the API Platform project. + * + * (c) Kévin Dunglas <dunglas@gmail.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\NotExposed; +use Doctrine\ORM\Mapping as ORM; + +/** + * Secured resource. + */ +#[ApiResource( + operations: [ + new NotExposed(), + ], + graphQlOperations: [ + new Query(), + new QueryCollection(), + ], + security: 'is_granted(\'ROLE_USER\')' +)] +#[ORM\Entity] +class SecuredDummyCollectionParent +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null; + + #[ORM\ManyToOne] + #[ORM\JoinColumn(nullable: false)] + public SecuredDummyCollection $child; +} diff --git a/tests/Functional/GraphQl/SecurityTest.php b/tests/Functional/GraphQl/SecurityTest.php new file mode 100644 index 00000000000..ce6eece7270 --- /dev/null +++ b/tests/Functional/GraphQl/SecurityTest.php @@ -0,0 +1,188 @@ +<?php + +/* + * This file is part of the API Platform project. + * + * (c) Kévin Dunglas <dunglas@gmail.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\GraphQl; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\SecuredDummy as DocumentSecuredDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\SecuredDummyCollection as DocumentSecuredDummyCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummyCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummyCollectionParent; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class SecurityTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [SecuredDummy::class, SecuredDummyCollection::class, SecuredDummyCollectionParent::class]; + } + + public function testQueryItem(): void + { + $resource = $this->isMongoDB() ? DocumentSecuredDummy::class : SecuredDummy::class; + $this->recreateSchema([$resource]); + $this->loadFixtures($resource); + $client = self::createClient(); + $response = $client->request('POST', '/graphql', ['json' => [ + 'query' => <<<QUERY + { + securedDummy(id: "/secured_dummies/1") { + title + description + } + } +QUERY, + ]]); + + $d = $response->toArray(); + $this->assertEquals('Access Denied.', $d['errors'][0]['message']); + } + + public function testCreateItemUnauthorized(): void + { + $resource = $this->isMongoDB() ? DocumentSecuredDummy::class : SecuredDummy::class; + $this->recreateSchema([$resource]); + $client = self::createClient(); + $response = $client->request('POST', '/graphql', ['json' => [ + 'query' => <<<QUERY +mutation { + createSecuredDummy(input: {owner: "me", title: "Hi", description: "Desc", adminOnlyProperty: "secret", clientMutationId: "auth"}) { + securedDummy { + title + owner + } + } +} +QUERY, + ]]); + + $d = $response->toArray(); + $this->assertEquals('Only admins can create a secured dummy.', $d['errors'][0]['message']); + } + + public function testQueryItemWithNode(): void + { + $resource = $this->isMongoDB() ? DocumentSecuredDummy::class : SecuredDummy::class; + $this->recreateSchema([$resource]); + $this->loadFixtures($resource); + $client = self::createClient(); + $response = $client->request('POST', '/graphql', ['json' => [ + 'query' => <<<QUERY + { + node(id: "/secured_dummies/1") { + ... on SecuredDummy { + title + } + } + } +QUERY, + ]]); + + $d = $response->toArray(); + $this->assertEquals('Access Denied.', $d['errors'][0]['message']); + } + + public function loadFixtures(string $resourceClass): void + { + $container = static::$kernel->getContainer(); + $registry = $this->isMongoDB() ? $container->get('doctrine_mongodb') : $container->get('doctrine'); + $manager = $registry->getManager(); + $s = new $resourceClass(); + $s->setTitle('Secured Dummy 1'); + $s->setDescription('Description 1'); + $s->setAdminOnlyProperty('admin secret'); + $s->setOwnerOnlyProperty('owner secret'); + $s->setAttributeBasedProperty('attribute based secret'); + $s->setOwner('user1'); + + $manager->persist($s); + $manager->flush(); + } + + public function testQueryCollection(): void + { + $resource = $this->isMongoDB() ? DocumentSecuredDummyCollection::class : SecuredDummyCollection::class; + $this->recreateSchema([$resource, $resource.'Parent']); + $this->loadFixturesQueryCollection($resource); + $client = self::createClient(); + + $response = $client->request('POST', '/graphql', ['headers' => ['Authorization' => 'Basic ZHVuZ2xhczprZXZpbg=='], 'json' => [ + 'query' => <<<QUERY + { + securedDummyCollectionParents { + edges { + node { + child { + title, ownerOnlyProperty, owner + } + } + } + } + } +QUERY, + ]]); + + $d = $response->toArray(); + $this->assertNull($d['data']['securedDummyCollectionParents']['edges'][1]['node']['child']['ownerOnlyProperty']); + } + + public function loadFixturesQueryCollection(string $resourceClass): void + { + $parentResourceClass = $resourceClass.'Parent'; + $container = static::$kernel->getContainer(); + $registry = $this->isMongoDB() ? $container->get('doctrine_mongodb') : $container->get('doctrine'); + $manager = $registry->getManager(); + $s = new $resourceClass(); + $s->title = 'Foo'; + $s->ownerOnlyProperty = 'Foo by dunglas'; + $s->owner = 'dunglas'; + $manager->persist($s); + $p = new $parentResourceClass(); + $p->child = $s; + $manager->persist($p); + $s = new $resourceClass(); + $s->title = 'Bar'; + $s->ownerOnlyProperty = 'Bar by admin'; + $s->owner = 'admin'; + $manager->persist($s); + $p = new $parentResourceClass(); + $p->child = $s; + $manager->persist($p); + $s = new $resourceClass(); + $s->title = 'Baz'; + $s->ownerOnlyProperty = 'Baz by dunglas'; + $s->owner = 'dunglas'; + $manager->persist($s); + $p = new $parentResourceClass(); + $p->child = $s; + $manager->persist($p); + $s = new $resourceClass(); + $s->ownerOnlyProperty = 'Bat by admin'; + $s->owner = 'admin'; + $s->title = 'Bat'; + $manager->persist($s); + $p = new $parentResourceClass(); + $p->child = $s; + $manager->persist($p); + $manager->flush(); + } +} From 55712452b4f630978537bdb2a07dc958202336bb Mon Sep 17 00:00:00 2001 From: Antoine Bluchet <soyuka@users.noreply.github.com> Date: Thu, 3 Apr 2025 16:56:14 +0200 Subject: [PATCH 2/5] fix(graphql): access to unauthorized resource using node Relay --- .../RuntimeOperationMetadataFactory.php | 55 +++++++ .../Resolver/Factory/ResolverFactory.php | 15 +- .../RuntimeOperationMetadataFactoryTest.php | 144 ++++++++++++++++++ .../Resolver/Factory/ResolverFactoryTest.php | 20 ++- .../Factory/OperationDefaultsTrait.php | 2 +- .../Bundle/Resources/config/graphql.xml | 6 + tests/Functional/GraphQl/SecurityTest.php | 1 - 7 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 src/GraphQl/Metadata/RuntimeOperationMetadataFactory.php create mode 100644 src/GraphQl/Tests/Metadata/RuntimeOperationMetadataFactoryTest.php diff --git a/src/GraphQl/Metadata/RuntimeOperationMetadataFactory.php b/src/GraphQl/Metadata/RuntimeOperationMetadataFactory.php new file mode 100644 index 00000000000..46fef884fec --- /dev/null +++ b/src/GraphQl/Metadata/RuntimeOperationMetadataFactory.php @@ -0,0 +1,55 @@ +<?php + +/* + * This file is part of the API Platform project. + * + * (c) Kévin Dunglas <dunglas@gmail.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\GraphQl\Metadata; + +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use Symfony\Component\Routing\Exception\ExceptionInterface as RoutingExceptionInterface; +use Symfony\Component\Routing\RouterInterface; + +/** + * This factory runs in the ResolverFactory and is used to find out a Relay node's operation. + */ +final class RuntimeOperationMetadataFactory implements OperationMetadataFactoryInterface +{ + public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly RouterInterface $router) + { + } + + public function create(string $uriTemplate, array $context = []): ?Operation + { + try { + $parameters = $this->router->match($uriTemplate); + } catch (RoutingExceptionInterface $e) { + throw new InvalidArgumentException(\sprintf('No route matches "%s".', $uriTemplate), $e->getCode(), $e); + } + + if (!isset($parameters['_api_resource_class'])) { + throw new InvalidArgumentException(\sprintf('The route "%s" is not an API route, it has no resource class in the defaults.', $uriTemplate)); + } + + foreach ($this->resourceMetadataCollectionFactory->create($parameters['_api_resource_class']) as $resource) { + foreach ($resource->getGraphQlOperations() ?? [] as $operation) { + if ($operation instanceof Query && !$operation->getResolver()) { + return $operation; + } + } + } + + throw new InvalidArgumentException(\sprintf('No operation found for id "%s".', $uriTemplate)); + } +} diff --git a/src/GraphQl/Resolver/Factory/ResolverFactory.php b/src/GraphQl/Resolver/Factory/ResolverFactory.php index c7ba03283ee..1aca8b41bab 100644 --- a/src/GraphQl/Resolver/Factory/ResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/ResolverFactory.php @@ -15,21 +15,28 @@ use ApiPlatform\GraphQl\State\Provider\NoopProvider; use ApiPlatform\Metadata\DeleteOperationInterface; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\State\Pagination\ArrayPaginator; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProviderInterface; use GraphQL\Type\Definition\ResolveInfo; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class ResolverFactory implements ResolverFactoryInterface { public function __construct( private readonly ProviderInterface $provider, private readonly ProcessorInterface $processor, + private readonly ?OperationMetadataFactoryInterface $operationMetadataFactory = null, ) { + if (!$operationMetadataFactory) { + throw new InvalidArgumentException(\sprintf('Not injecting the "%s" exposes Relay nodes to a security risk.', OperationMetadataFactoryInterface::class)); + } } public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?Operation $operation = null, ?PropertyMetadataFactoryInterface $propertyMetadataFactory = null): callable @@ -70,7 +77,13 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul private function resolve(?array $source, array $args, ResolveInfo $info, ?string $rootClass = null, ?Operation $operation = null, mixed $body = null) { // Handles relay nodes - $operation ??= new Query(); + if (!$operation) { + if (!isset($args['id'])) { + throw new NotFoundHttpException('No node found.'); + } + + $operation = $this->operationMetadataFactory->create($args['id']); + } $graphQlContext = []; $context = ['source' => $source, 'args' => $args, 'info' => $info, 'root_class' => $rootClass, 'graphql_context' => &$graphQlContext]; diff --git a/src/GraphQl/Tests/Metadata/RuntimeOperationMetadataFactoryTest.php b/src/GraphQl/Tests/Metadata/RuntimeOperationMetadataFactoryTest.php new file mode 100644 index 00000000000..5dfbfc22351 --- /dev/null +++ b/src/GraphQl/Tests/Metadata/RuntimeOperationMetadataFactoryTest.php @@ -0,0 +1,144 @@ +<?php + +/* + * This file is part of the API Platform project. + * + * (c) Kévin Dunglas <dunglas@gmail.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\GraphQl\Tests\Metadata; + +use ApiPlatform\GraphQl\Metadata\RuntimeOperationMetadataFactory; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; +use Symfony\Component\Routing\RouterInterface; + +class RuntimeOperationMetadataFactoryTest extends TestCase +{ + public function testCreate(): void + { + $resourceClass = 'Dummy'; + $operationName = 'item_query'; + + $operation = (new Query())->withName($operationName); + $resourceMetadata = (new ApiResource())->withGraphQlOperations([$operationName => $operation]); + $resourceMetadataCollection = new ResourceMetadataCollection($resourceClass, [$resourceMetadata]); + + $resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->expects($this->once()) + ->method('create') + ->with($resourceClass) + ->willReturn($resourceMetadataCollection); + + $router = $this->createMock(RouterInterface::class); + $router->expects($this->once()) + ->method('match') + ->with('/dummies/1') + ->willReturn([ + '_api_resource_class' => $resourceClass, + '_api_operation_name' => $operationName, + ]); + + $factory = new RuntimeOperationMetadataFactory($resourceMetadataCollectionFactory, $router); + $this->assertEquals($operation, $factory->create('/dummies/1')); + } + + public function testCreateThrowsExceptionWhenRouteNotFound(): void + { + $this->expectException(\ApiPlatform\Metadata\Exception\InvalidArgumentException::class); + $this->expectExceptionMessage('No route matches "/unknown".'); + + $router = $this->createMock(RouterInterface::class); + $router->expects($this->once()) + ->method('match') + ->with('/unknown') + ->willThrowException(new ResourceNotFoundException()); + + $resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + + $factory = new RuntimeOperationMetadataFactory($resourceMetadataCollectionFactory, $router); + $factory->create('/unknown'); + } + + public function testCreateThrowsExceptionWhenResourceClassMissing(): void + { + $this->expectException(\ApiPlatform\Metadata\Exception\InvalidArgumentException::class); + $this->expectExceptionMessage('The route "/dummies/1" is not an API route, it has no resource class in the defaults.'); + + $router = $this->createMock(RouterInterface::class); + $router->expects($this->once()) + ->method('match') + ->with('/dummies/1') + ->willReturn([]); + + $resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + + $factory = new RuntimeOperationMetadataFactory($resourceMetadataCollectionFactory, $router); + $factory->create('/dummies/1'); + } + + public function testCreateThrowsExceptionWhenOperationNotFound(): void + { + $this->expectException(\ApiPlatform\Metadata\Exception\InvalidArgumentException::class); + $this->expectExceptionMessage('No operation found for id "/dummies/1".'); + + $resourceClass = 'Dummy'; + + $resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->expects($this->once()) + ->method('create') + ->with($resourceClass) + ->willReturn(new ResourceMetadataCollection($resourceClass, [new ApiResource()])); + + $router = $this->createMock(RouterInterface::class); + $router->expects($this->once()) + ->method('match') + ->with('/dummies/1') + ->willReturn([ + '_api_resource_class' => $resourceClass, + ]); + + $factory = new RuntimeOperationMetadataFactory($resourceMetadataCollectionFactory, $router); + $factory->create('/dummies/1'); + } + + public function testCreateIgnoresOperationsWithResolvers(): void + { + $this->expectException(\ApiPlatform\Metadata\Exception\InvalidArgumentException::class); + $this->expectExceptionMessage('No operation found for id "/dummies/1".'); + + $resourceClass = 'Dummy'; + $operationName = 'item_query'; + + $operation = (new Query())->withResolver('t')->withName($operationName); + $resourceMetadata = (new ApiResource())->withGraphQlOperations([$operationName => $operation]); + $resourceMetadataCollection = new ResourceMetadataCollection($resourceClass, [$resourceMetadata]); + + $resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->expects($this->once()) + ->method('create') + ->with($resourceClass) + ->willReturn($resourceMetadataCollection); + + $router = $this->createMock(RouterInterface::class); + $router->expects($this->once()) + ->method('match') + ->with('/dummies/1') + ->willReturn([ + '_api_resource_class' => $resourceClass, + '_api_operation_name' => $operationName, + ]); + + $factory = new RuntimeOperationMetadataFactory($resourceMetadataCollectionFactory, $router); + $factory->create('/dummies/1'); + } +} diff --git a/src/GraphQl/Tests/Resolver/Factory/ResolverFactoryTest.php b/src/GraphQl/Tests/Resolver/Factory/ResolverFactoryTest.php index ee0be4aef60..8720c1b2a3c 100644 --- a/src/GraphQl/Tests/Resolver/Factory/ResolverFactoryTest.php +++ b/src/GraphQl/Tests/Resolver/Factory/ResolverFactoryTest.php @@ -18,6 +18,7 @@ use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProviderInterface; @@ -45,7 +46,7 @@ public function testGraphQlResolver(?string $resourceClass = null, ?string $root $resolveInfo = $this->createMock(ResolveInfo::class); $resolveInfo->fieldName = 'test'; - $resolverFactory = new ResolverFactory($provider, $processor); + $resolverFactory = new ResolverFactory($provider, $processor, $this->createMock(OperationMetadataFactoryInterface::class)); $this->assertEquals($resolverFactory->__invoke($resourceClass, $rootClass, $operation, $propertyMetadataFactory)(['test' => null], [], [], $resolveInfo), $returnValue); } @@ -56,4 +57,21 @@ public static function graphQlQueries(): array ['Dummy', 'Dummy', new Mutation(), (new Mutation())->withValidate(true), (new Mutation())->withValidate(true)->withWrite(true)], ]; } + + public function testGraphQlResolverWithNode(): void + { + $returnValue = new \stdClass(); + $op = new Query(name: 'hi'); + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once())->method('provide')->with($op)->willReturn($returnValue); + $processor = $this->createMock(ProcessorInterface::class); + $processor->expects($this->once())->method('process')->with($returnValue, $op)->willReturn($returnValue); + $resolveInfo = $this->createMock(ResolveInfo::class); + $resolveInfo->fieldName = 'test'; + + $operationFactory = $this->createMock(OperationMetadataFactoryInterface::class); + $operationFactory->method('create')->with('/foo')->willReturn($op); + $resolverFactory = new ResolverFactory($provider, $processor, $operationFactory); + $this->assertSame($returnValue, $resolverFactory->__invoke()([], ['id' => '/foo'], [], $resolveInfo)); + } } diff --git a/src/Metadata/Resource/Factory/OperationDefaultsTrait.php b/src/Metadata/Resource/Factory/OperationDefaultsTrait.php index dab37b23dae..f83cd4e80af 100644 --- a/src/Metadata/Resource/Factory/OperationDefaultsTrait.php +++ b/src/Metadata/Resource/Factory/OperationDefaultsTrait.php @@ -112,7 +112,7 @@ private function getDefaultHttpOperations($resource): iterable private function addDefaultGraphQlOperations(ApiResource $resource): ApiResource { - $operations = enum_exists($resource->getClass()) ? [new QueryCollection(paginationEnabled: false), new Query()] : [new QueryCollection(), new Query(), (new Mutation())->withName('update'), (new DeleteMutation())->withName('delete'), (new Mutation())->withName('create')]; + $operations = enum_exists($resource->getClass()) ? [new Query(), new QueryCollection(paginationEnabled: false)] : [new Query(), new QueryCollection(), (new Mutation())->withName('update'), (new DeleteMutation())->withName('delete'), (new Mutation())->withName('create')]; $graphQlOperations = []; foreach ($operations as $operation) { [$key, $operation] = $this->getOperationWithDefaults($resource, $operation); diff --git a/src/Symfony/Bundle/Resources/config/graphql.xml b/src/Symfony/Bundle/Resources/config/graphql.xml index f532c6ab500..807b7173247 100644 --- a/src/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Symfony/Bundle/Resources/config/graphql.xml @@ -191,6 +191,12 @@ <service id="api_platform.graphql.resolver.factory.item" class="ApiPlatform\GraphQl\Resolver\Factory\ResolverFactory" public="false"> <argument type="service" id="api_platform.graphql.state_provider" /> <argument type="service" id="api_platform.graphql.state_processor" /> + <argument type="service" id="api_platform.graphql.runtime_operation_metadata_factory" /> + </service> + + <service id="api_platform.graphql.runtime_operation_metadata_factory" class="ApiPlatform\GraphQl\Metadata\RuntimeOperationMetadataFactory" public="false"> + <argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" /> + <argument type="service" id="api_platform.router" /> </service> <!-- Resolver Stages --> diff --git a/tests/Functional/GraphQl/SecurityTest.php b/tests/Functional/GraphQl/SecurityTest.php index ce6eece7270..f63b85cf355 100644 --- a/tests/Functional/GraphQl/SecurityTest.php +++ b/tests/Functional/GraphQl/SecurityTest.php @@ -26,7 +26,6 @@ final class SecurityTest extends ApiTestCase { use RecreateSchemaTrait; use SetupClassResourcesTrait; - protected static ?bool $alwaysBootKernel = false; /** * @return class-string[] From c65015ee33a6d598cda55169eae29ddb51e1226e Mon Sep 17 00:00:00 2001 From: Antoine Bluchet <soyuka@users.noreply.github.com> Date: Thu, 3 Apr 2025 18:57:52 +0200 Subject: [PATCH 3/5] test: various fixes (#7063) --- .../TestBundle/Document/SecuredDummyCollection.php | 2 +- .../Document/SecuredDummyCollectionParent.php | 10 ++++------ tests/Functional/GraphQl/SecurityTest.php | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/Fixtures/TestBundle/Document/SecuredDummyCollection.php b/tests/Fixtures/TestBundle/Document/SecuredDummyCollection.php index 9f46e84e0b4..7356c404f86 100644 --- a/tests/Fixtures/TestBundle/Document/SecuredDummyCollection.php +++ b/tests/Fixtures/TestBundle/Document/SecuredDummyCollection.php @@ -36,7 +36,7 @@ #[ODM\Document] class SecuredDummyCollection { - #[ODM\Id(strategy: 'AUTO', type: 'integer')] + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] public ?int $id = null; /** diff --git a/tests/Fixtures/TestBundle/Document/SecuredDummyCollectionParent.php b/tests/Fixtures/TestBundle/Document/SecuredDummyCollectionParent.php index ee1843be412..efe23f5372d 100644 --- a/tests/Fixtures/TestBundle/Document/SecuredDummyCollectionParent.php +++ b/tests/Fixtures/TestBundle/Document/SecuredDummyCollectionParent.php @@ -35,11 +35,9 @@ #[ODM\Document] class SecuredDummyCollectionParent { - #[ODM\Id] - #[ODM\Field(type: 'id')] - public ?string $id = null; + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + public ?int $id = null; - #[ODM\ReferenceOne(targetDocument: SecuredDummyCollection::class, inversedBy: 'parents')] - #[ODM\Field(nullable: false)] - public SecuredDummyCollection $child; + #[ODM\ReferenceOne(targetDocument: SecuredDummyCollection::class)] + public ?SecuredDummyCollection $child = null; } diff --git a/tests/Functional/GraphQl/SecurityTest.php b/tests/Functional/GraphQl/SecurityTest.php index f63b85cf355..1dd97d37aac 100644 --- a/tests/Functional/GraphQl/SecurityTest.php +++ b/tests/Functional/GraphQl/SecurityTest.php @@ -15,8 +15,8 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\Document\SecuredDummy as DocumentSecuredDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Document\SecuredDummyCollection as DocumentSecuredDummyCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummyCollection; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummyCollectionParent; use ApiPlatform\Tests\RecreateSchemaTrait; From 945ae51df4909537156d1f25199265fc41a681cd Mon Sep 17 00:00:00 2001 From: soyuka <soyuka@users.noreply.github.com> Date: Mon, 7 Apr 2025 10:30:15 +0200 Subject: [PATCH 4/5] chore: remove 4.0 test --- tests/Functional/GraphQl/SecurityTest.php | 187 ---------------------- 1 file changed, 187 deletions(-) delete mode 100644 tests/Functional/GraphQl/SecurityTest.php diff --git a/tests/Functional/GraphQl/SecurityTest.php b/tests/Functional/GraphQl/SecurityTest.php deleted file mode 100644 index 1dd97d37aac..00000000000 --- a/tests/Functional/GraphQl/SecurityTest.php +++ /dev/null @@ -1,187 +0,0 @@ -<?php - -/* - * This file is part of the API Platform project. - * - * (c) Kévin Dunglas <dunglas@gmail.com> - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Functional\GraphQl; - -use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\SecuredDummy as DocumentSecuredDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\SecuredDummyCollection as DocumentSecuredDummyCollection; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummyCollection; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummyCollectionParent; -use ApiPlatform\Tests\RecreateSchemaTrait; -use ApiPlatform\Tests\SetupClassResourcesTrait; - -final class SecurityTest extends ApiTestCase -{ - use RecreateSchemaTrait; - use SetupClassResourcesTrait; - - /** - * @return class-string[] - */ - public static function getResources(): array - { - return [SecuredDummy::class, SecuredDummyCollection::class, SecuredDummyCollectionParent::class]; - } - - public function testQueryItem(): void - { - $resource = $this->isMongoDB() ? DocumentSecuredDummy::class : SecuredDummy::class; - $this->recreateSchema([$resource]); - $this->loadFixtures($resource); - $client = self::createClient(); - $response = $client->request('POST', '/graphql', ['json' => [ - 'query' => <<<QUERY - { - securedDummy(id: "/secured_dummies/1") { - title - description - } - } -QUERY, - ]]); - - $d = $response->toArray(); - $this->assertEquals('Access Denied.', $d['errors'][0]['message']); - } - - public function testCreateItemUnauthorized(): void - { - $resource = $this->isMongoDB() ? DocumentSecuredDummy::class : SecuredDummy::class; - $this->recreateSchema([$resource]); - $client = self::createClient(); - $response = $client->request('POST', '/graphql', ['json' => [ - 'query' => <<<QUERY -mutation { - createSecuredDummy(input: {owner: "me", title: "Hi", description: "Desc", adminOnlyProperty: "secret", clientMutationId: "auth"}) { - securedDummy { - title - owner - } - } -} -QUERY, - ]]); - - $d = $response->toArray(); - $this->assertEquals('Only admins can create a secured dummy.', $d['errors'][0]['message']); - } - - public function testQueryItemWithNode(): void - { - $resource = $this->isMongoDB() ? DocumentSecuredDummy::class : SecuredDummy::class; - $this->recreateSchema([$resource]); - $this->loadFixtures($resource); - $client = self::createClient(); - $response = $client->request('POST', '/graphql', ['json' => [ - 'query' => <<<QUERY - { - node(id: "/secured_dummies/1") { - ... on SecuredDummy { - title - } - } - } -QUERY, - ]]); - - $d = $response->toArray(); - $this->assertEquals('Access Denied.', $d['errors'][0]['message']); - } - - public function loadFixtures(string $resourceClass): void - { - $container = static::$kernel->getContainer(); - $registry = $this->isMongoDB() ? $container->get('doctrine_mongodb') : $container->get('doctrine'); - $manager = $registry->getManager(); - $s = new $resourceClass(); - $s->setTitle('Secured Dummy 1'); - $s->setDescription('Description 1'); - $s->setAdminOnlyProperty('admin secret'); - $s->setOwnerOnlyProperty('owner secret'); - $s->setAttributeBasedProperty('attribute based secret'); - $s->setOwner('user1'); - - $manager->persist($s); - $manager->flush(); - } - - public function testQueryCollection(): void - { - $resource = $this->isMongoDB() ? DocumentSecuredDummyCollection::class : SecuredDummyCollection::class; - $this->recreateSchema([$resource, $resource.'Parent']); - $this->loadFixturesQueryCollection($resource); - $client = self::createClient(); - - $response = $client->request('POST', '/graphql', ['headers' => ['Authorization' => 'Basic ZHVuZ2xhczprZXZpbg=='], 'json' => [ - 'query' => <<<QUERY - { - securedDummyCollectionParents { - edges { - node { - child { - title, ownerOnlyProperty, owner - } - } - } - } - } -QUERY, - ]]); - - $d = $response->toArray(); - $this->assertNull($d['data']['securedDummyCollectionParents']['edges'][1]['node']['child']['ownerOnlyProperty']); - } - - public function loadFixturesQueryCollection(string $resourceClass): void - { - $parentResourceClass = $resourceClass.'Parent'; - $container = static::$kernel->getContainer(); - $registry = $this->isMongoDB() ? $container->get('doctrine_mongodb') : $container->get('doctrine'); - $manager = $registry->getManager(); - $s = new $resourceClass(); - $s->title = 'Foo'; - $s->ownerOnlyProperty = 'Foo by dunglas'; - $s->owner = 'dunglas'; - $manager->persist($s); - $p = new $parentResourceClass(); - $p->child = $s; - $manager->persist($p); - $s = new $resourceClass(); - $s->title = 'Bar'; - $s->ownerOnlyProperty = 'Bar by admin'; - $s->owner = 'admin'; - $manager->persist($s); - $p = new $parentResourceClass(); - $p->child = $s; - $manager->persist($p); - $s = new $resourceClass(); - $s->title = 'Baz'; - $s->ownerOnlyProperty = 'Baz by dunglas'; - $s->owner = 'dunglas'; - $manager->persist($s); - $p = new $parentResourceClass(); - $p->child = $s; - $manager->persist($p); - $s = new $resourceClass(); - $s->ownerOnlyProperty = 'Bat by admin'; - $s->owner = 'admin'; - $s->title = 'Bat'; - $manager->persist($s); - $p = new $parentResourceClass(); - $p->child = $s; - $manager->persist($p); - $manager->flush(); - } -} From c5fb664d17ed9ae919394514ea69a5039d2ad9ab Mon Sep 17 00:00:00 2001 From: soyuka <soyuka@users.noreply.github.com> Date: Mon, 7 Apr 2025 10:40:48 +0200 Subject: [PATCH 5/5] docs: changelog 3.4.17 --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17c0a4195a9..3be2c274f36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v3.4.17 + +### Bug fixes + +* [55712452b](https://github.com/api-platform/core/commit/55712452b4f630978537bdb2a07dc958202336bb) fix(graphql): access to unauthorized resource using node Relay +* [7cb5a6db8](https://github.com/api-platform/core/commit/7cb5a6db87241d95e6c324318fe861bd4f1820cf) fix: allow parameter provider as object (#7032) +* [cba3acfbd](https://github.com/api-platform/core/commit/cba3acfbd517763cf320167250c5bed6d569696a) fix(graphql): property security might be cached w/ different objects +* [da2e86809](https://github.com/api-platform/core/commit/da2e86809d4a8dec294dc2fc148d92406f1f7fd1) fix: header parameter should be case insensitive (#7031) +* [f4c426d71](https://github.com/api-platform/core/commit/f4c426d719b01debaa993b00d03cce8964057ecc) Revert "fix(doctrine): throw an exception when a filter is not found in a par…" (#7046) + ## v3.4.16 ### Bug fixes