Skip to content

Commit c14b6f4

Browse files
xavierleuneXavier Leune
andauthored
fix(graphql): add cache_key in item normalizer (#5686)
Co-authored-by: Xavier Leune <[email protected]>
1 parent c2b3514 commit c14b6f4

File tree

2 files changed

+128
-1
lines changed

2 files changed

+128
-1
lines changed

src/GraphQl/Serializer/ItemNormalizer.php

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
2222
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2323
use ApiPlatform\Metadata\Util\ClassInfoTrait;
24+
use ApiPlatform\Serializer\CacheKeyTrait;
2425
use ApiPlatform\Serializer\ItemNormalizer as BaseItemNormalizer;
2526
use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface;
2627
use Psr\Log\LoggerInterface;
@@ -37,12 +38,15 @@
3738
*/
3839
final class ItemNormalizer extends BaseItemNormalizer
3940
{
41+
use CacheKeyTrait;
4042
use ClassInfoTrait;
4143

4244
public const FORMAT = 'graphql';
4345
public const ITEM_RESOURCE_CLASS_KEY = '#itemResourceClass';
4446
public const ITEM_IDENTIFIERS_KEY = '#itemIdentifiers';
4547

48+
private array $safeCacheKeysCache = [];
49+
4650
public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, private readonly IdentifiersExtractorInterface $identifiersExtractor, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, LoggerInterface $logger = null, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ResourceAccessCheckerInterface $resourceAccessChecker = null)
4751
{
4852
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $logger ?: new NullLogger(), $resourceMetadataCollectionFactory, $resourceAccessChecker);
@@ -81,7 +85,11 @@ public function normalize(mixed $object, string $format = null, array $context =
8185
return parent::normalize($object, $format, $context);
8286
}
8387

84-
unset($context['operation_name'], $context['operation']);
88+
if ($this->isCacheKeySafe($context)) {
89+
$context['cache_key'] = $this->getCacheKey($format, $context);
90+
}
91+
92+
unset($context['operation_name'], $context['operation']); // Remove operation and operation_name only when cache key has been created
8593
$data = parent::normalize($object, $format, $context);
8694
if (!\is_array($data)) {
8795
throw new UnexpectedValueException('Expected data to be an array.');
@@ -140,4 +148,32 @@ protected function setAttributeValue($object, $attribute, $value, $format = null
140148

141149
parent::setAttributeValue($object, $attribute, $value, $format, $context);
142150
}
151+
152+
/**
153+
* Check if any property contains a security grants, which makes the cache key not safe,
154+
* as allowed_properties can differ for 2 instances of the same object.
155+
*/
156+
private function isCacheKeySafe(array $context): bool
157+
{
158+
if (!isset($context['resource_class']) || !$this->resourceClassResolver->isResourceClass($context['resource_class'])) {
159+
return false;
160+
}
161+
$resourceClass = $this->resourceClassResolver->getResourceClass(null, $context['resource_class']);
162+
if (isset($this->safeCacheKeysCache[$resourceClass])) {
163+
return $this->safeCacheKeysCache[$resourceClass];
164+
}
165+
$options = $this->getFactoryOptions($context);
166+
$propertyNames = $this->propertyNameCollectionFactory->create($resourceClass, $options);
167+
168+
$this->safeCacheKeysCache[$resourceClass] = true;
169+
foreach ($propertyNames as $propertyName) {
170+
$propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName, $options);
171+
if (null !== $propertyMetadata->getSecurity()) {
172+
$this->safeCacheKeysCache[$resourceClass] = false;
173+
break;
174+
}
175+
}
176+
177+
return $this->safeCacheKeysCache[$resourceClass];
178+
}
143179
}

tests/GraphQl/Serializer/ItemNormalizerTest.php

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
2323
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
2424
use ApiPlatform\Metadata\Property\PropertyNameCollection;
25+
use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface;
2526
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy;
27+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummy;
2628
use PHPUnit\Framework\TestCase;
2729
use Prophecy\Argument;
2830
use Prophecy\PhpUnit\ProphecyTrait;
@@ -115,6 +117,95 @@ public function testNormalize(): void
115117
];
116118
$this->assertEquals($expected, $normalizer->normalize($dummy, ItemNormalizer::FORMAT, [
117119
'resources' => [],
120+
'resource_class' => Dummy::class,
121+
]));
122+
}
123+
124+
public function testNormalizeWithUnsafeCacheProperty(): void
125+
{
126+
$securedDummyWithOwnerOnlyPropertyAllowed = new SecuredDummy();
127+
$securedDummyWithOwnerOnlyPropertyAllowed->setTitle('hello');
128+
$securedDummyWithOwnerOnlyPropertyAllowed->setOwnerOnlyProperty('ownerOnly');
129+
$securedDummyWithoutOwnerOnlyPropertyAllowed = clone $securedDummyWithOwnerOnlyPropertyAllowed;
130+
$securedDummyWithoutOwnerOnlyPropertyAllowed->setTitle('hello from secured dummy');
131+
132+
$propertyNameCollection = new PropertyNameCollection(['title', 'ownerOnlyProperty']);
133+
$propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);
134+
$propertyNameCollectionFactoryProphecy->create(SecuredDummy::class, [])->willReturn($propertyNameCollection);
135+
136+
$unsecuredPropertyMetadata = (new ApiProperty())->withReadable(true);
137+
$securedPropertyMetadata = (new ApiProperty())->withReadable(true)->withSecurity('object == null or object.getOwner() == user');
138+
$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
139+
$propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', [])->willReturn($unsecuredPropertyMetadata);
140+
$propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', [])->willReturn($securedPropertyMetadata);
141+
142+
$iriConverterProphecy = $this->prophesize(IriConverterInterface::class);
143+
$iriConverterProphecy->getIriFromResource($securedDummyWithOwnerOnlyPropertyAllowed, UrlGeneratorInterface::ABS_URL, Argument::any(), Argument::type('array'))->willReturn('/dummies/1');
144+
$iriConverterProphecy->getIriFromResource($securedDummyWithoutOwnerOnlyPropertyAllowed, UrlGeneratorInterface::ABS_URL, Argument::any(), Argument::type('array'))->willReturn('/dummies/2');
145+
146+
$identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class);
147+
$identifiersExtractorProphecy->getIdentifiersFromItem($securedDummyWithOwnerOnlyPropertyAllowed, Argument::any())->willReturn(['id' => 1])->shouldBeCalled();
148+
$identifiersExtractorProphecy->getIdentifiersFromItem($securedDummyWithoutOwnerOnlyPropertyAllowed, Argument::any())->willReturn(['id' => 2])->shouldBeCalled();
149+
150+
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
151+
$resourceClassResolverProphecy->getResourceClass($securedDummyWithOwnerOnlyPropertyAllowed, null)->willReturn(SecuredDummy::class);
152+
$resourceClassResolverProphecy->getResourceClass($securedDummyWithoutOwnerOnlyPropertyAllowed, null)->willReturn(SecuredDummy::class);
153+
$resourceClassResolverProphecy->getResourceClass(null, SecuredDummy::class)->willReturn(SecuredDummy::class);
154+
$resourceClassResolverProphecy->isResourceClass(SecuredDummy::class)->willReturn(true);
155+
156+
$serializerProphecy = $this->prophesize(SerializerInterface::class);
157+
$serializerProphecy->willImplement(NormalizerInterface::class);
158+
$serializerProphecy->normalize('hello', ItemNormalizer::FORMAT, Argument::type('array'))->willReturn('hello');
159+
$serializerProphecy->normalize('hello from secured dummy', ItemNormalizer::FORMAT, Argument::type('array'))->willReturn('hello from secured dummy');
160+
$serializerProphecy->normalize('ownerOnly', ItemNormalizer::FORMAT, Argument::type('array'))->willReturn('ownerOnly');
161+
162+
$resourceAccessCheckerProphecy = $this->prophesize(ResourceAccessCheckerInterface::class);
163+
$resourceAccessCheckerProphecy->isGranted(
164+
SecuredDummy::class,
165+
'object == null or object.getOwner() == user',
166+
Argument::type('array')
167+
)->will(function (array $args) {
168+
return 'hello' === $args[2]['object']->getTitle(); // Allow access only for securedDummyWithOwnerOnlyPropertyAllowed
169+
});
170+
171+
$normalizer = new ItemNormalizer(
172+
$propertyNameCollectionFactoryProphecy->reveal(),
173+
$propertyMetadataFactoryProphecy->reveal(),
174+
$iriConverterProphecy->reveal(),
175+
$identifiersExtractorProphecy->reveal(),
176+
$resourceClassResolverProphecy->reveal(),
177+
null,
178+
null,
179+
null,
180+
null,
181+
null,
182+
$resourceAccessCheckerProphecy->reveal()
183+
);
184+
$normalizer->setSerializer($serializerProphecy->reveal());
185+
186+
$expected = [
187+
'title' => 'hello',
188+
'ownerOnlyProperty' => 'ownerOnly',
189+
ItemNormalizer::ITEM_RESOURCE_CLASS_KEY => SecuredDummy::class,
190+
ItemNormalizer::ITEM_IDENTIFIERS_KEY => [
191+
'id' => 1,
192+
],
193+
];
194+
$this->assertEquals($expected, $normalizer->normalize($securedDummyWithOwnerOnlyPropertyAllowed, ItemNormalizer::FORMAT, [
195+
'resources' => [],
196+
'resource_class' => SecuredDummy::class,
197+
]));
198+
199+
$expected = [
200+
'title' => 'hello from secured dummy',
201+
ItemNormalizer::ITEM_RESOURCE_CLASS_KEY => SecuredDummy::class,
202+
ItemNormalizer::ITEM_IDENTIFIERS_KEY => [
203+
'id' => 2,
204+
],
205+
];
206+
$this->assertEquals($expected, $normalizer->normalize($securedDummyWithoutOwnerOnlyPropertyAllowed, ItemNormalizer::FORMAT, [
207+
'resources' => [],
208+
'resource_class' => SecuredDummy::class,
118209
]));
119210
}
120211

0 commit comments

Comments
 (0)