Skip to content

Commit 0b985ae

Browse files
feat(state): add security to parameters (#6435)
* fix(state): add security to parameters * chore(state): fix style
1 parent 74986cb commit 0b985ae

File tree

7 files changed

+207
-24
lines changed

7 files changed

+207
-24
lines changed

src/Metadata/Link.php

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ public function __construct(
2727
private ?array $identifiers = null,
2828
private ?bool $compositeIdentifier = null,
2929
private ?string $expandedValue = null,
30-
private ?string $security = null,
31-
private ?string $securityMessage = null,
30+
?string $security = null,
31+
?string $securityMessage = null,
3232
private ?string $securityObjectName = null,
3333

3434
?string $key = null,
@@ -55,6 +55,8 @@ public function __construct(
5555
property: $property,
5656
description: $description,
5757
required: $required,
58+
security: $security,
59+
securityMessage: $securityMessage,
5860
extraProperties: $extraProperties
5961
);
6062
}
@@ -168,27 +170,6 @@ public function getSecurity(): ?string
168170
return $this->security;
169171
}
170172

171-
public function getSecurityMessage(): ?string
172-
{
173-
return $this->securityMessage;
174-
}
175-
176-
public function withSecurity(?string $security): self
177-
{
178-
$self = clone $this;
179-
$self->security = $security;
180-
181-
return $self;
182-
}
183-
184-
public function withSecurityMessage(?string $securityMessage): self
185-
{
186-
$self = clone $this;
187-
$self->securityMessage = $securityMessage;
188-
189-
return $self;
190-
}
191-
192173
public function getSecurityObjectName(): ?string
193174
{
194175
return $this->securityObjectName;

src/Metadata/Parameter.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ public function __construct(
4040
protected ?bool $required = null,
4141
protected ?int $priority = null,
4242
protected Constraint|array|null $constraints = null,
43+
protected string|\Stringable|null $security = null,
44+
protected ?string $securityMessage = null,
4345
protected ?array $extraProperties = [],
4446
) {
4547
}
@@ -100,6 +102,16 @@ public function getConstraints(): Constraint|array|null
100102
return $this->constraints;
101103
}
102104

105+
public function getSecurity(): string|\Stringable|null
106+
{
107+
return $this->security;
108+
}
109+
110+
public function getSecurityMessage(): ?string
111+
{
112+
return $this->securityMessage;
113+
}
114+
103115
/**
104116
* @return array<string, mixed>
105117
*/
@@ -197,6 +209,22 @@ public function withConstraints(array|Constraint $constraints): static
197209
return $self;
198210
}
199211

212+
public function withSecurity(string|\Stringable|null $security): self
213+
{
214+
$self = clone $this;
215+
$self->security = $security;
216+
217+
return $self;
218+
}
219+
220+
public function withSecurityMessage(?string $securityMessage): self
221+
{
222+
$self = clone $this;
223+
$self->securityMessage = $securityMessage;
224+
225+
return $self;
226+
}
227+
200228
/**
201229
* @param array<string, mixed> $extraProperties
202230
*/
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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\State\Provider;
15+
16+
use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;
17+
use ApiPlatform\Metadata\Operation;
18+
use ApiPlatform\Metadata\ResourceAccessCheckerInterface;
19+
use ApiPlatform\State\ProviderInterface;
20+
use ApiPlatform\State\Util\ParameterParserTrait;
21+
use ApiPlatform\Symfony\Security\Exception\AccessDeniedException;
22+
use Symfony\Component\HttpFoundation\Request;
23+
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
24+
25+
/**
26+
* Loops over parameters to check parameter security.
27+
* Throws an exception if security is not granted.
28+
*/
29+
final class SecurityParameterProvider implements ProviderInterface
30+
{
31+
use ParameterParserTrait;
32+
33+
public function __construct(private readonly ?ProviderInterface $decorated = null, private readonly ?ResourceAccessCheckerInterface $resourceAccessChecker = null)
34+
{
35+
}
36+
37+
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
38+
{
39+
if (!($request = $context['request']) instanceof Request) {
40+
return $this->decorated->provide($operation, $uriVariables, $context);
41+
}
42+
43+
/** @var Operation $apiOperation */
44+
$apiOperation = $request->attributes->get('_api_operation');
45+
46+
foreach ($apiOperation->getParameters() ?? [] as $parameter) {
47+
if (null === $security = $parameter->getSecurity()) {
48+
continue;
49+
}
50+
51+
$key = $this->getParameterFlattenKey($parameter->getKey(), $this->extractParameterValues($parameter, $request, $context));
52+
$apiValues = $parameter->getExtraProperties()['_api_values'] ?? [];
53+
if (!isset($apiValues[$key])) {
54+
continue;
55+
}
56+
$value = $apiValues[$key];
57+
58+
if (!$this->resourceAccessChecker->isGranted($context['resource_class'], $security, [$key => $value])) {
59+
throw $operation instanceof GraphQlOperation ? new AccessDeniedHttpException($parameter->getSecurityMessage() ?? 'Access Denied.') : new AccessDeniedException($parameter->getSecurityMessage() ?? 'Access Denied.');
60+
}
61+
}
62+
63+
return $this->decorated->provide($operation, $uriVariables, $context);
64+
}
65+
}

src/Symfony/Bundle/Resources/config/state/provider.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,5 +48,10 @@
4848
<argument type="service" id="api_platform.state_provider.parameter.inner" />
4949
<argument type="tagged_locator" tag="api_platform.parameter_provider" index-by="key" />
5050
</service>
51+
52+
<service id="api_platform.state_provider.security_parameter" class="ApiPlatform\State\Provider\SecurityParameterProvider" decorates="api_platform.state_provider.main" decoration-priority="200">
53+
<argument type="service" id="api_platform.state_provider.security_parameter.inner" />
54+
<argument type="service" id="api_platform.security.resource_access_checker" />
55+
</service>
5156
</services>
5257
</container>

src/Symfony/Bundle/Resources/config/state/state.xml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@
5454
<tag name="api_platform.state_provider" key="api_platform.state_provider.object" />
5555
</service>
5656
<service id="ApiPlatform\State\ObjectProvider" alias="api_platform.state_provider.object" />
57-
5857
<service id="ApiPlatform\State\SerializerContextBuilderInterface" alias="api_platform.serializer.context_builder" />
5958
</services>
6059
</container>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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\Tests\Fixtures\TestBundle\ApiResource;
15+
16+
use ApiPlatform\Metadata\GetCollection;
17+
use ApiPlatform\Metadata\HeaderParameter;
18+
use ApiPlatform\Metadata\QueryParameter;
19+
20+
#[GetCollection(
21+
uriTemplate: 'with_security_parameters_collection{._format}',
22+
parameters: [
23+
'name' => new QueryParameter(security: 'is_granted("ROLE_ADMIN")'),
24+
'auth' => new HeaderParameter(security: '"secured" == auth[0]'),
25+
'secret' => new QueryParameter(security: '"secured" == secret'),
26+
],
27+
provider: [self::class, 'collectionProvider'],
28+
)]
29+
class WithSecurityParameter
30+
{
31+
public static function collectionProvider()
32+
{
33+
return [new self()];
34+
}
35+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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\Tests\Functional\Parameters;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use Symfony\Component\HttpFoundation\Response;
18+
use Symfony\Component\Security\Core\User\InMemoryUser;
19+
20+
class SecurityTests extends ApiTestCase
21+
{
22+
public function dataUserAuthorization(): iterable
23+
{
24+
yield [['ROLE_ADMIN'], Response::HTTP_OK];
25+
yield [['ROLE_USER'], Response::HTTP_FORBIDDEN];
26+
}
27+
28+
/** @dataProvider dataUserAuthorization */
29+
public function testUserAuthorization(array $roles, int $expectedStatusCode): void
30+
{
31+
$client = self::createClient();
32+
$client->loginUser(new InMemoryUser('emmanuel', 'password', $roles));
33+
34+
$client->request('GET', 'with_security_parameters_collection?name=foo');
35+
$this->assertResponseStatusCodeSame($expectedStatusCode);
36+
}
37+
38+
public function testNoValueParameter(): void
39+
{
40+
$client = self::createClient();
41+
$client->loginUser(new InMemoryUser('emmanuel', 'password', ['ROLE_ADMIN']));
42+
43+
$client->request('GET', 'with_security_parameters_collection?name');
44+
$this->assertResponseIsSuccessful();
45+
}
46+
47+
public function dataSecurityValues(): iterable
48+
{
49+
yield ['secured', Response::HTTP_OK];
50+
yield ['not_the_expected_parameter_value', Response::HTTP_UNAUTHORIZED];
51+
}
52+
53+
/** @dataProvider dataSecurityValues */
54+
public function testSecurityHeaderValues(string $parameterValue, int $expectedStatusCode): void
55+
{
56+
self::createClient()->request('GET', 'with_security_parameters_collection', [
57+
'headers' => [
58+
'auth' => $parameterValue,
59+
],
60+
]);
61+
$this->assertResponseStatusCodeSame($expectedStatusCode);
62+
}
63+
64+
/** @dataProvider dataSecurityValues */
65+
public function testSecurityQueryValues(string $parameterValue, int $expectedStatusCode): void
66+
{
67+
self::createClient()->request('GET', sprintf('with_security_parameters_collection?secret=%s', $parameterValue));
68+
$this->assertResponseStatusCodeSame($expectedStatusCode);
69+
}
70+
}

0 commit comments

Comments
 (0)