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