Skip to content

Commit 1290ebb

Browse files
dwgeblersoyuka
andauthored
feat(serializer): ability to throw access denied exception when denormalizing secured properties (#7221)
Co-authored-by: Antoine Bluchet <soyuka@users.noreply.github.com>
1 parent 42f0ce7 commit 1290ebb

File tree

3 files changed

+181
-1
lines changed

3 files changed

+181
-1
lines changed

src/Serializer/AbstractItemNormalizer.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
use ApiPlatform\Metadata\ApiProperty;
1717
use ApiPlatform\Metadata\CollectionOperationInterface;
18+
use ApiPlatform\Metadata\Exception\AccessDeniedException;
1819
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
1920
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
2021
use ApiPlatform\Metadata\IriConverterInterface;
@@ -266,18 +267,27 @@ public function denormalize(mixed $data, string $class, ?string $format = null,
266267
$options = $this->getFactoryOptions($context);
267268
$propertyNames = iterator_to_array($this->propertyNameCollectionFactory->create($resourceClass, $options));
268269

270+
$operation = $context['operation'] ?? null;
271+
$throwOnAccessDenied = $operation?->getExtraProperties()['throw_on_access_denied'] ?? false;
272+
$securityMessage = $operation?->getSecurityMessage() ?? null;
273+
269274
// Revert attributes that aren't allowed to be changed after a post-denormalize check
270275
foreach (array_keys($data) as $attribute) {
271276
$attribute = $this->nameConverter ? $this->nameConverter->denormalize((string) $attribute) : $attribute;
277+
$propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $attribute, $options);
278+
$attributeExtraProperties = $propertyMetadata->getExtraProperties() ?? [];
279+
$throwOnPropertyAccessDenied = $attributeExtraProperties['throw_on_access_denied'] ?? $throwOnAccessDenied;
272280
if (!\in_array($attribute, $propertyNames, true)) {
273281
continue;
274282
}
275283

276284
if (!$this->canAccessAttributePostDenormalize($object, $previousObject, $attribute, $context)) {
285+
if ($throwOnPropertyAccessDenied) {
286+
throw new AccessDeniedException($securityMessage ?? 'Access denied');
287+
}
277288
if (null !== $previousObject) {
278289
$this->setValue($object, $attribute, $this->propertyAccessor->getValue($previousObject, $attribute));
279290
} else {
280-
$propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $attribute, $options);
281291
$this->setValue($object, $attribute, $propertyMetadata->getDefault());
282292
}
283293
}

src/Serializer/Tests/AbstractItemNormalizerTest.php

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@
1515

1616
use ApiPlatform\Metadata\ApiProperty;
1717
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\Exception\AccessDeniedException;
1819
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
1920
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
2021
use ApiPlatform\Metadata\Get;
2122
use ApiPlatform\Metadata\GetCollection;
2223
use ApiPlatform\Metadata\IriConverterInterface;
2324
use ApiPlatform\Metadata\Operations;
25+
use ApiPlatform\Metadata\Patch;
2426
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
2527
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
2628
use ApiPlatform\Metadata\Property\PropertyNameCollection;
@@ -306,6 +308,173 @@ public function testNormalizePropertyAsIriWithUriTemplate(): void
306308
]));
307309
}
308310

311+
public function testDenormalizeWithSecuredPropertyAndThrowOnAccessDeniedExtraPropertyInAttributeMetaThrowsAccessDeniedExceptionWithSecurityMessage(): void
312+
{
313+
$data = [
314+
'title' => 'foo',
315+
'adminOnlyProperty' => 'secret',
316+
];
317+
$propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);
318+
$propertyNameCollectionFactoryProphecy->create(SecuredDummy::class, [])->willReturn(new PropertyNameCollection(['title', 'adminOnlyProperty']));
319+
320+
$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
321+
322+
if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
323+
$propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', [])->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true));
324+
$propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', [])->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')')->withExtraProperties(['throw_on_access_denied' => true]));
325+
} else {
326+
$propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', [])->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true));
327+
$propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', [])->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')')->withExtraProperties(['throw_on_access_denied' => true]));
328+
}
329+
330+
$iriConverterProphecy = $this->prophesize(IriConverterInterface::class);
331+
332+
$propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class);
333+
334+
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
335+
$resourceClassResolverProphecy->getResourceClass(null, SecuredDummy::class)->willReturn(SecuredDummy::class);
336+
$resourceClassResolverProphecy->isResourceClass(SecuredDummy::class)->willReturn(true);
337+
338+
$serializerProphecy = $this->prophesize(SerializerInterface::class);
339+
$serializerProphecy->willImplement(NormalizerInterface::class);
340+
341+
$resourceAccessChecker = $this->prophesize(ResourceAccessCheckerInterface::class);
342+
$resourceAccessChecker->isGranted(
343+
SecuredDummy::class,
344+
'is_granted(\'ROLE_ADMIN\')',
345+
Argument::that(function (array $context) {
346+
return \array_key_exists('property', $context)
347+
&& \array_key_exists('object', $context)
348+
&& \array_key_exists('previous_object', $context)
349+
&& 'adminOnlyProperty' === $context['property']
350+
&& null === $context['previous_object']
351+
&& $context['object'] instanceof SecuredDummy;
352+
})
353+
)->willReturn(false);
354+
355+
$normalizer = new class($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), null, null, [], null, $resourceAccessChecker->reveal()) extends AbstractItemNormalizer {};
356+
$normalizer->setSerializer($serializerProphecy->reveal());
357+
358+
$this->expectException(AccessDeniedException::class);
359+
$this->expectExceptionMessage('Access denied');
360+
361+
$normalizer->denormalize($data, SecuredDummy::class);
362+
}
363+
364+
public function testDenormalizeWithSecuredPropertyAndThrowOnAccessDeniedExtraPropertyInOperationAndSecurityMessageInOperationThrowsAccessDeniedExceptionWithSecurityMessage(): void
365+
{
366+
$data = [
367+
'title' => 'foo',
368+
'adminOnlyProperty' => 'secret',
369+
];
370+
$propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);
371+
$propertyNameCollectionFactoryProphecy->create(SecuredDummy::class, [])->willReturn(new PropertyNameCollection(['title', 'adminOnlyProperty']));
372+
373+
$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
374+
375+
if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
376+
$propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', [])->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true));
377+
$propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', [])->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')'));
378+
} else {
379+
$propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', [])->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true));
380+
$propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', [])->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')'));
381+
}
382+
383+
$iriConverterProphecy = $this->prophesize(IriConverterInterface::class);
384+
385+
$propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class);
386+
387+
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
388+
$resourceClassResolverProphecy->getResourceClass(null, SecuredDummy::class)->willReturn(SecuredDummy::class);
389+
$resourceClassResolverProphecy->isResourceClass(SecuredDummy::class)->willReturn(true);
390+
391+
$serializerProphecy = $this->prophesize(SerializerInterface::class);
392+
$serializerProphecy->willImplement(NormalizerInterface::class);
393+
394+
$resourceAccessChecker = $this->prophesize(ResourceAccessCheckerInterface::class);
395+
$resourceAccessChecker->isGranted(
396+
SecuredDummy::class,
397+
'is_granted(\'ROLE_ADMIN\')',
398+
Argument::that(function (array $context) {
399+
return \array_key_exists('property', $context)
400+
&& \array_key_exists('object', $context)
401+
&& \array_key_exists('previous_object', $context)
402+
&& 'adminOnlyProperty' === $context['property']
403+
&& null === $context['previous_object']
404+
&& $context['object'] instanceof SecuredDummy;
405+
})
406+
)->willReturn(false);
407+
408+
$normalizer = new class($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), null, null, [], null, $resourceAccessChecker->reveal()) extends AbstractItemNormalizer {};
409+
$normalizer->setSerializer($serializerProphecy->reveal());
410+
411+
$this->expectException(AccessDeniedException::class);
412+
$this->expectExceptionMessage('Custom access denied message');
413+
414+
$operation = new Patch(securityMessage: 'Custom access denied message', extraProperties: ['throw_on_access_denied' => true]);
415+
416+
$normalizer->denormalize($data, SecuredDummy::class, 'json', [
417+
'operation' => $operation,
418+
]);
419+
}
420+
421+
public function testDenormalizeWithSecuredPropertyAndThrowOnAccessDeniedExtraPropertyInOperationThrowsAccessDeniedException(): void
422+
{
423+
$data = [
424+
'title' => 'foo',
425+
'adminOnlyProperty' => 'secret',
426+
];
427+
$propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);
428+
$propertyNameCollectionFactoryProphecy->create(SecuredDummy::class, [])->willReturn(new PropertyNameCollection(['title', 'adminOnlyProperty']));
429+
430+
$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
431+
432+
if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
433+
$propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', [])->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true));
434+
$propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', [])->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')'));
435+
} else {
436+
$propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', [])->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true));
437+
$propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', [])->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')'));
438+
}
439+
440+
$iriConverterProphecy = $this->prophesize(IriConverterInterface::class);
441+
442+
$propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class);
443+
444+
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
445+
$resourceClassResolverProphecy->getResourceClass(null, SecuredDummy::class)->willReturn(SecuredDummy::class);
446+
$resourceClassResolverProphecy->isResourceClass(SecuredDummy::class)->willReturn(true);
447+
448+
$serializerProphecy = $this->prophesize(SerializerInterface::class);
449+
$serializerProphecy->willImplement(NormalizerInterface::class);
450+
451+
$resourceAccessChecker = $this->prophesize(ResourceAccessCheckerInterface::class);
452+
$resourceAccessChecker->isGranted(
453+
SecuredDummy::class,
454+
'is_granted(\'ROLE_ADMIN\')',
455+
Argument::that(function (array $context) {
456+
return \array_key_exists('property', $context)
457+
&& \array_key_exists('object', $context)
458+
&& \array_key_exists('previous_object', $context)
459+
&& 'adminOnlyProperty' === $context['property']
460+
&& null === $context['previous_object']
461+
&& $context['object'] instanceof SecuredDummy;
462+
})
463+
)->willReturn(false);
464+
465+
$normalizer = new class($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), null, null, [], null, $resourceAccessChecker->reveal()) extends AbstractItemNormalizer {};
466+
$normalizer->setSerializer($serializerProphecy->reveal());
467+
468+
$this->expectException(AccessDeniedException::class);
469+
$this->expectExceptionMessage('Access denied');
470+
471+
$operation = new Patch(extraProperties: ['throw_on_access_denied' => true]);
472+
473+
$normalizer->denormalize($data, SecuredDummy::class, 'json', [
474+
'operation' => $operation,
475+
]);
476+
}
477+
309478
public function testDenormalizeWithSecuredProperty(): void
310479
{
311480
$data = [

src/Serializer/Tests/ItemNormalizerTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@ public function testDenormalizeWithWrongId(): void
323323
$propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true);
324324
$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
325325
$propertyMetadataFactoryProphecy->create(Dummy::class, 'name', [])->willReturn($propertyMetadata)->shouldBeCalled();
326+
$propertyMetadataFactoryProphecy->create(Dummy::class, 'id', [])->willReturn($propertyMetadata)->shouldBeCalled();
326327

327328
$iriConverterProphecy = $this->prophesize(IriConverterInterface::class);
328329
$iriConverterProphecy->getResourceFromIri('fail', $context + ['fetch_data' => true])->willThrow(new InvalidArgumentException());

0 commit comments

Comments
 (0)