Skip to content

Commit 594e4bf

Browse files
authored
feat: add support for security_post_validation (#4392)
1 parent 3238500 commit 594e4bf

File tree

14 files changed

+339
-2
lines changed

14 files changed

+339
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
* DataProvider: new `ApiPlatform\State\ProviderInterface` that replaces DataProviders (#4351)
4343
* DataPersister: new `ApiPlatform\State\ProcessorInterface` that replaces DataPersisters (#4351)
4444
* A new configuration is available to keep old services (IriConverter, IdentifiersExtractor and OpenApiFactory) `metadata_backward_compatibility_layer` (defaults to false) (#4351)
45+
* Add support for `security_post_validation` attribute
4546

4647
## 2.6.5
4748

src/Core/Annotation/ApiResource.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@
6363
* @Attribute("securityMessage", type="string"),
6464
* @Attribute("securityPostDenormalize", type="string"),
6565
* @Attribute("securityPostDenormalizeMessage", type="string"),
66+
* @Attribute("securityPostValidation", type="string"),
67+
* @Attribute("securityPostValidationMessage", type="string"),
6668
* @Attribute("shortName", type="string"),
6769
* @Attribute("stateless", type="bool"),
6870
* @Attribute("subresourceOperations", type="array"),
@@ -167,6 +169,8 @@ final class ApiResource
167169
* @param string $securityMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message
168170
* @param string $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization
169171
* @param string $securityPostDenormalizeMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message
172+
* @param string $securityPostValidation https://api-platform.com/docs/core/security/#executing-access-control-rules-after-validation
173+
* @param string $securityPostValidationMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message
170174
* @param bool $stateless
171175
* @param string $sunset https://api-platform.com/docs/core/deprecations/#setting-the-sunset-http-header-to-indicate-when-a-resource-or-an-operation-will-be-removed
172176
* @param array $swaggerContext https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts
@@ -218,6 +222,8 @@ public function __construct(
218222
?string $securityMessage = null,
219223
?string $securityPostDenormalize = null,
220224
?string $securityPostDenormalizeMessage = null,
225+
?string $securityPostValidation = null,
226+
?string $securityPostValidationMessage = null,
221227
?bool $stateless = null,
222228
?string $sunset = null,
223229
?array $swaggerContext = null,

src/Core/Bridge/Symfony/Bundle/Resources/config/graphql.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
<argument type="service" id="api_platform.graphql.resolver.stage.validate" />
3939
<argument type="service" id="api_platform.graphql.mutation_resolver_locator" />
4040
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
41+
<argument type="service" id="api_platform.graphql.resolver.stage.security_post_validation" />
4142
</service>
4243

4344
<service id="api_platform.graphql.resolver.factory.item_subscription" class="ApiPlatform\GraphQl\Resolver\Factory\ItemSubscriptionResolverFactory" public="false">
@@ -70,6 +71,11 @@
7071
<argument type="service" id="api_platform.security.resource_access_checker" on-invalid="ignore" />
7172
</service>
7273

74+
<service id="api_platform.graphql.resolver.stage.security_post_validation" class="ApiPlatform\GraphQl\Resolver\Stage\SecurityPostValidationStage" public="false">
75+
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
76+
<argument type="service" id="api_platform.security.resource_access_checker" on-invalid="ignore" />
77+
</service>
78+
7379
<service id="api_platform.graphql.resolver.stage.serialize" class="ApiPlatform\GraphQl\Resolver\Stage\SerializeStage" public="false">
7480
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
7581
<argument type="service" id="serializer" />

src/Core/Bridge/Symfony/Bundle/Resources/config/security.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
<tag name="kernel.event_listener" event="kernel.request" method="onSecurity" priority="3" />
2525
<!-- This method must be executed only when the current object is available, after deserialization -->
2626
<tag name="kernel.event_listener" event="kernel.request" method="onSecurityPostDenormalize" priority="1" />
27+
<!-- This method must be executed only when the current object is available, after validation -->
28+
<tag name="kernel.event_listener" event="kernel.view" method="onSecurityPostValidation" priority="63" />
2729
</service>
2830

2931
<service id="api_platform.security.expression_language_provider" class="ApiPlatform\Core\Security\Core\Authorization\ExpressionLanguageProvider" public="false">

src/Core/Security/EventListener/DenyAccessListener.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use ApiPlatform\Util\OperationRequestInitiatorTrait;
2323
use Symfony\Component\HttpFoundation\Request;
2424
use Symfony\Component\HttpKernel\Event\RequestEvent;
25+
use Symfony\Component\HttpKernel\Event\ViewEvent;
2526
use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface;
2627
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
2728
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
@@ -85,6 +86,14 @@ public function onSecurityPostDenormalize(RequestEvent $event): void
8586
]);
8687
}
8788

89+
public function onSecurityPostValidation(ViewEvent $event): void
90+
{
91+
$request = $event->getRequest();
92+
$this->checkSecurity($request, 'security_post_validation', false, [
93+
'previous_object' => $request->attributes->get('previous_data'),
94+
]);
95+
}
96+
8897
/**
8998
* @throws AccessDeniedException
9099
*/

src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use ApiPlatform\GraphQl\Resolver\Stage\DeserializeStageInterface;
2020
use ApiPlatform\GraphQl\Resolver\Stage\ReadStageInterface;
2121
use ApiPlatform\GraphQl\Resolver\Stage\SecurityPostDenormalizeStageInterface;
22+
use ApiPlatform\GraphQl\Resolver\Stage\SecurityPostValidationStageInterface;
2223
use ApiPlatform\GraphQl\Resolver\Stage\SecurityStageInterface;
2324
use ApiPlatform\GraphQl\Resolver\Stage\SerializeStageInterface;
2425
use ApiPlatform\GraphQl\Resolver\Stage\ValidateStageInterface;
@@ -49,8 +50,9 @@ final class ItemMutationResolverFactory implements ResolverFactoryInterface
4950
private $validateStage;
5051
private $mutationResolverLocator;
5152
private $resourceMetadataCollectionFactory;
53+
private $securityPostValidationStage;
5254

53-
public function __construct(ReadStageInterface $readStage, SecurityStageInterface $securityStage, SecurityPostDenormalizeStageInterface $securityPostDenormalizeStage, SerializeStageInterface $serializeStage, DeserializeStageInterface $deserializeStage, WriteStageInterface $writeStage, ValidateStageInterface $validateStage, ContainerInterface $mutationResolverLocator, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory)
55+
public function __construct(ReadStageInterface $readStage, SecurityStageInterface $securityStage, SecurityPostDenormalizeStageInterface $securityPostDenormalizeStage, SerializeStageInterface $serializeStage, DeserializeStageInterface $deserializeStage, WriteStageInterface $writeStage, ValidateStageInterface $validateStage, ContainerInterface $mutationResolverLocator, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, SecurityPostValidationStageInterface $securityPostValidationStage)
5456
{
5557
$this->readStage = $readStage;
5658
$this->securityStage = $securityStage;
@@ -61,6 +63,7 @@ public function __construct(ReadStageInterface $readStage, SecurityStageInterfac
6163
$this->validateStage = $validateStage;
6264
$this->mutationResolverLocator = $mutationResolverLocator;
6365
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
66+
$this->securityPostValidationStage = $securityPostValidationStage;
6467
}
6568

6669
public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?string $operationName = null): callable
@@ -120,6 +123,13 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul
120123
if (null !== $item) {
121124
($this->validateStage)($item, $resourceClass, $operationName, $resolverContext);
122125

126+
($this->securityPostValidationStage)($resourceClass, $operationName, $resolverContext + [
127+
'extra_variables' => [
128+
'object' => $item,
129+
'previous_object' => $previousItem,
130+
],
131+
]);
132+
123133
$persistResult = ($this->writeStage)($item, $resourceClass, $operationName, $resolverContext);
124134
}
125135

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\GraphQl\Resolver\Stage;
15+
16+
use ApiPlatform\Core\Security\ResourceAccessCheckerInterface;
17+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
18+
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
19+
20+
/**
21+
* Security post validation stage of GraphQL resolvers.
22+
*
23+
* @experimental
24+
*
25+
* @author Vincent Chalamon <[email protected]>
26+
* @author Grégoire Pineau <[email protected]>
27+
*/
28+
final class SecurityPostValidationStage implements SecurityPostValidationStageInterface
29+
{
30+
private $resourceMetadataCollectionFactory;
31+
private $resourceAccessChecker;
32+
33+
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ?ResourceAccessCheckerInterface $resourceAccessChecker)
34+
{
35+
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
36+
$this->resourceAccessChecker = $resourceAccessChecker;
37+
}
38+
39+
/**
40+
* {@inheritdoc}
41+
*/
42+
public function __invoke(string $resourceClass, string $operationName, array $context): void
43+
{
44+
$resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass);
45+
$operation = $resourceMetadataCollection->getGraphQlOperation($operationName);
46+
$isGranted = $operation->getSecurityPostValidation();
47+
48+
if (null !== $isGranted && null === $this->resourceAccessChecker) {
49+
throw new \LogicException('Cannot check security expression when SecurityBundle is not installed. Try running "composer require symfony/security-bundle".');
50+
}
51+
52+
if (null === $isGranted || $this->resourceAccessChecker->isGranted($resourceClass, (string) $isGranted, $context['extra_variables'])) {
53+
return;
54+
}
55+
56+
throw new AccessDeniedHttpException($operation->getSecurityPostValidationMessage() ?? 'Access Denied.');
57+
}
58+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\GraphQl\Resolver\Stage;
15+
16+
use GraphQL\Error\Error;
17+
18+
/**
19+
* Security post validation stage of GraphQL resolvers.
20+
*
21+
* @experimental
22+
*
23+
* @author Vincent Chalamon <[email protected]>
24+
* @author Grégoire Pineau <[email protected]>
25+
*/
26+
interface SecurityPostValidationStageInterface
27+
{
28+
/**
29+
* @throws Error
30+
*/
31+
public function __invoke(string $resourceClass, string $operationName, array $context): void;
32+
}

src/Metadata/ApiResource.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ final class ApiResource
105105
private $securityMessage;
106106
private $securityPostDenormalize;
107107
private $securityPostDenormalizeMessage;
108+
private $securityPostValidation;
109+
private $securityPostValidationMessage;
108110
private $compositeIdentifier;
109111
private $exceptionToStatus;
110112
private $queryParameterValidationEnabled;
@@ -151,6 +153,8 @@ final class ApiResource
151153
* @param string|null $securityMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message
152154
* @param string|null $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization
153155
* @param string|null $securityPostDenormalizeMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message
156+
* @param string $securityPostValidation https://api-platform.com/docs/core/security/#executing-access-control-rules-after-validtion
157+
* @param string $securityPostValidationMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message
154158
*/
155159
public function __construct(
156160
?string $uriTemplate = null,
@@ -207,6 +211,8 @@ public function __construct(
207211
?string $securityMessage = null,
208212
?string $securityPostDenormalize = null,
209213
?string $securityPostDenormalizeMessage = null,
214+
?string $securityPostValidation = null,
215+
?string $securityPostValidationMessage = null,
210216
?bool $compositeIdentifier = null,
211217
?array $exceptionToStatus = null,
212218
?bool $queryParameterValidationEnabled = null,
@@ -267,6 +273,8 @@ public function __construct(
267273
$this->securityMessage = $securityMessage;
268274
$this->securityPostDenormalize = $securityPostDenormalize;
269275
$this->securityPostDenormalizeMessage = $securityPostDenormalizeMessage;
276+
$this->securityPostValidation = $securityPostValidation;
277+
$this->securityPostValidationMessage = $securityPostValidationMessage;
270278
$this->compositeIdentifier = $compositeIdentifier;
271279
$this->exceptionToStatus = $exceptionToStatus;
272280
$this->queryParameterValidationEnabled = $queryParameterValidationEnabled;
@@ -1012,6 +1020,32 @@ public function withSecurityPostDenormalizeMessage(string $securityPostDenormali
10121020
return $self;
10131021
}
10141022

1023+
public function getSecurityPostValidation(): ?string
1024+
{
1025+
return $this->securityPostValidation;
1026+
}
1027+
1028+
public function withSecurityPostValidation(?string $securityPostValidation = null): self
1029+
{
1030+
$self = clone $this;
1031+
$self->securityPostValidation = $securityPostValidation;
1032+
1033+
return $self;
1034+
}
1035+
1036+
public function getSecurityPostValidationMessage(): ?string
1037+
{
1038+
return $this->securityPostValidationMessage;
1039+
}
1040+
1041+
public function withSecurityPostValidationMessage(?string $securityPostValidationMessage = null): self
1042+
{
1043+
$self = clone $this;
1044+
$self->securityPostValidationMessage = $securityPostValidationMessage;
1045+
1046+
return $self;
1047+
}
1048+
10151049
public function getCompositeIdentifier(): ?bool
10161050
{
10171051
return $this->compositeIdentifier;

src/Metadata/GraphQl/Operation.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ class Operation
4242
protected $securityMessage;
4343
protected $securityPostDenormalize;
4444
protected $securityPostDenormalizeMessage;
45+
protected $securityPostValidation;
46+
protected $securityPostValidationMessage;
4547
protected $deprecationReason;
4648
/**
4749
* @var string[]
@@ -96,6 +98,8 @@ class Operation
9698
* @param string $securityMessage
9799
* @param string $securityPostDenormalize
98100
* @param string $securityPostDenormalizeMessage
101+
* @param string $securityPostValidation
102+
* @param string $securityPostValidationMessage
99103
* @param string $deprecationReason
100104
* @param string[] $filters
101105
* @param bool|string|array $mercure
@@ -131,6 +135,8 @@ public function __construct(
131135
?string $securityMessage = null,
132136
?string $securityPostDenormalize = null,
133137
?string $securityPostDenormalizeMessage = null,
138+
?string $securityPostValidation = null,
139+
?string $securityPostValidationMessage = null,
134140
?string $deprecationReason = null,
135141
?array $filters = null,
136142
?array $validationContext = null,
@@ -174,6 +180,8 @@ public function __construct(
174180
$this->securityMessage = $securityMessage;
175181
$this->securityPostDenormalize = $securityPostDenormalize;
176182
$this->securityPostDenormalizeMessage = $securityPostDenormalizeMessage;
183+
$this->securityPostValidation = $securityPostValidation;
184+
$this->securityPostValidationMessage = $securityPostValidationMessage;
177185
$this->deprecationReason = $deprecationReason;
178186
$this->filters = $filters;
179187
$this->validationContext = $validationContext;
@@ -499,6 +507,32 @@ public function withSecurityPostDenormalizeMessage(?string $securityPostDenormal
499507
return $self;
500508
}
501509

510+
public function getSecurityPostValidation(): ?string
511+
{
512+
return $this->securityPostValidation;
513+
}
514+
515+
public function withSecurityPostValidation(?string $securityPostValidation = null): self
516+
{
517+
$self = clone $this;
518+
$self->securityPostValidation = $securityPostValidation;
519+
520+
return $self;
521+
}
522+
523+
public function getSecurityPostValidationMessage(): ?string
524+
{
525+
return $this->securityPostValidationMessage;
526+
}
527+
528+
public function withSecurityPostValidationMessage(?string $securityPostValidationMessage = null): self
529+
{
530+
$self = clone $this;
531+
$self->securityPostValidationMessage = $securityPostValidationMessage;
532+
533+
return $self;
534+
}
535+
502536
public function getDeprecationReason(): ?string
503537
{
504538
return $this->deprecationReason;

tests/Core/Annotation/ApiResourceTest.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ public function testConstruct()
3939
'securityMessage' => 'You are not foo.',
4040
'securityPostDenormalize' => 'is_granted("ROLE_BAR")',
4141
'securityPostDenormalizeMessage' => 'You are not bar.',
42+
'securityPostValidation' => 'is_granted("ROLE_FOO")',
43+
'securityPostValidationMessage' => 'You are not foo.',
4244
'attributes' => ['foo' => 'bar', 'validation_groups' => ['baz', 'qux'], 'cache_headers' => ['max_age' => 0, 'shared_max_age' => 0, 'vary' => ['Custom-Vary-1', 'Custom-Vary-2']]],
4345
'collectionOperations' => ['bar' => ['foo']],
4446
'denormalizationContext' => ['groups' => ['foo']],
@@ -87,6 +89,8 @@ public function testConstruct()
8789
'security_message' => 'You are not foo.',
8890
'security_post_denormalize' => 'is_granted("ROLE_BAR")',
8991
'security_post_denormalize_message' => 'You are not bar.',
92+
'security_post_validation' => 'is_granted("ROLE_FOO")',
93+
'security_post_validation_message' => 'You are not foo.',
9094
'denormalization_context' => ['groups' => ['foo']],
9195
'fetch_partial' => true,
9296
'foo' => 'bar',
@@ -129,6 +133,8 @@ public function testConstructAttribute()
129133
securityMessage: 'You are not foo.',
130134
securityPostDenormalize: 'is_granted("ROLE_BAR")',
131135
securityPostDenormalizeMessage: 'You are not bar.',
136+
securityPostValidation: 'is_granted("ROLE_FOO")',
137+
securityPostValidationMessage: 'You are not foo.',
132138
attributes: ['foo' => 'bar', 'validation_groups' => ['baz', 'qux'], 'cache_headers' => ['max_age' => 0, 'shared_max_age' => 0, 'vary' => ['Custom-Vary-1', 'Custom-Vary-2']]],
133139
collectionOperations: ['bar' => ['foo']],
134140
denormalizationContext: ['groups' => ['foo']],
@@ -187,6 +193,8 @@ public function testConstructAttribute()
187193
'security_message' => 'You are not foo.',
188194
'security_post_denormalize' => 'is_granted("ROLE_BAR")',
189195
'security_post_denormalize_message' => 'You are not bar.',
196+
'security_post_validation' => 'is_granted("ROLE_FOO")',
197+
'security_post_validation_message' => 'You are not foo.',
190198
'denormalization_context' => ['groups' => ['foo']],
191199
'fetch_partial' => true,
192200
'foo' => 'bar',

0 commit comments

Comments
 (0)