Skip to content

Commit

Permalink
feat(state): add security to parameters (#6435)
Browse files Browse the repository at this point in the history
* fix(state): add security to parameters

* chore(state): fix style
  • Loading branch information
emmanuel-averty authored Jul 4, 2024
1 parent 74986cb commit 0b985ae
Show file tree
Hide file tree
Showing 7 changed files with 207 additions and 24 deletions.
27 changes: 4 additions & 23 deletions src/Metadata/Link.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ public function __construct(
private ?array $identifiers = null,
private ?bool $compositeIdentifier = null,
private ?string $expandedValue = null,
private ?string $security = null,
private ?string $securityMessage = null,
?string $security = null,
?string $securityMessage = null,
private ?string $securityObjectName = null,

?string $key = null,
Expand All @@ -55,6 +55,8 @@ public function __construct(
property: $property,
description: $description,
required: $required,
security: $security,
securityMessage: $securityMessage,
extraProperties: $extraProperties
);
}
Expand Down Expand Up @@ -168,27 +170,6 @@ public function getSecurity(): ?string
return $this->security;
}

public function getSecurityMessage(): ?string
{
return $this->securityMessage;
}

public function withSecurity(?string $security): self
{
$self = clone $this;
$self->security = $security;

return $self;
}

public function withSecurityMessage(?string $securityMessage): self
{
$self = clone $this;
$self->securityMessage = $securityMessage;

return $self;
}

public function getSecurityObjectName(): ?string
{
return $this->securityObjectName;
Expand Down
28 changes: 28 additions & 0 deletions src/Metadata/Parameter.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ public function __construct(
protected ?bool $required = null,
protected ?int $priority = null,
protected Constraint|array|null $constraints = null,
protected string|\Stringable|null $security = null,
protected ?string $securityMessage = null,
protected ?array $extraProperties = [],
) {
}
Expand Down Expand Up @@ -100,6 +102,16 @@ public function getConstraints(): Constraint|array|null
return $this->constraints;
}

public function getSecurity(): string|\Stringable|null
{
return $this->security;
}

public function getSecurityMessage(): ?string
{
return $this->securityMessage;
}

/**
* @return array<string, mixed>
*/
Expand Down Expand Up @@ -197,6 +209,22 @@ public function withConstraints(array|Constraint $constraints): static
return $self;
}

public function withSecurity(string|\Stringable|null $security): self
{
$self = clone $this;
$self->security = $security;

return $self;
}

public function withSecurityMessage(?string $securityMessage): self
{
$self = clone $this;
$self->securityMessage = $securityMessage;

return $self;
}

/**
* @param array<string, mixed> $extraProperties
*/
Expand Down
65 changes: 65 additions & 0 deletions src/State/Provider/SecurityParameterProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?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\State\Provider;

use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\ResourceAccessCheckerInterface;
use ApiPlatform\State\ProviderInterface;
use ApiPlatform\State\Util\ParameterParserTrait;
use ApiPlatform\Symfony\Security\Exception\AccessDeniedException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

/**
* Loops over parameters to check parameter security.
* Throws an exception if security is not granted.
*/
final class SecurityParameterProvider implements ProviderInterface
{
use ParameterParserTrait;

public function __construct(private readonly ?ProviderInterface $decorated = null, private readonly ?ResourceAccessCheckerInterface $resourceAccessChecker = null)
{
}

public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
if (!($request = $context['request']) instanceof Request) {
return $this->decorated->provide($operation, $uriVariables, $context);
}

/** @var Operation $apiOperation */
$apiOperation = $request->attributes->get('_api_operation');

foreach ($apiOperation->getParameters() ?? [] as $parameter) {
if (null === $security = $parameter->getSecurity()) {
continue;
}

$key = $this->getParameterFlattenKey($parameter->getKey(), $this->extractParameterValues($parameter, $request, $context));
$apiValues = $parameter->getExtraProperties()['_api_values'] ?? [];
if (!isset($apiValues[$key])) {
continue;
}
$value = $apiValues[$key];

if (!$this->resourceAccessChecker->isGranted($context['resource_class'], $security, [$key => $value])) {
throw $operation instanceof GraphQlOperation ? new AccessDeniedHttpException($parameter->getSecurityMessage() ?? 'Access Denied.') : new AccessDeniedException($parameter->getSecurityMessage() ?? 'Access Denied.');
}
}

return $this->decorated->provide($operation, $uriVariables, $context);
}
}
5 changes: 5 additions & 0 deletions src/Symfony/Bundle/Resources/config/state/provider.xml
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,10 @@
<argument type="service" id="api_platform.state_provider.parameter.inner" />
<argument type="tagged_locator" tag="api_platform.parameter_provider" index-by="key" />
</service>

<service id="api_platform.state_provider.security_parameter" class="ApiPlatform\State\Provider\SecurityParameterProvider" decorates="api_platform.state_provider.main" decoration-priority="200">
<argument type="service" id="api_platform.state_provider.security_parameter.inner" />
<argument type="service" id="api_platform.security.resource_access_checker" />
</service>
</services>
</container>
1 change: 0 additions & 1 deletion src/Symfony/Bundle/Resources/config/state/state.xml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@
<tag name="api_platform.state_provider" key="api_platform.state_provider.object" />
</service>
<service id="ApiPlatform\State\ObjectProvider" alias="api_platform.state_provider.object" />

<service id="ApiPlatform\State\SerializerContextBuilderInterface" alias="api_platform.serializer.context_builder" />
</services>
</container>
35 changes: 35 additions & 0 deletions tests/Fixtures/TestBundle/ApiResource/WithSecurityParameter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?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\ApiResource;

use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\HeaderParameter;
use ApiPlatform\Metadata\QueryParameter;

#[GetCollection(
uriTemplate: 'with_security_parameters_collection{._format}',
parameters: [
'name' => new QueryParameter(security: 'is_granted("ROLE_ADMIN")'),
'auth' => new HeaderParameter(security: '"secured" == auth[0]'),
'secret' => new QueryParameter(security: '"secured" == secret'),
],
provider: [self::class, 'collectionProvider'],
)]
class WithSecurityParameter
{
public static function collectionProvider()
{
return [new self()];
}
}
70 changes: 70 additions & 0 deletions tests/Functional/Parameters/SecurityTests.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?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\Parameters;

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\User\InMemoryUser;

class SecurityTests extends ApiTestCase
{
public function dataUserAuthorization(): iterable
{
yield [['ROLE_ADMIN'], Response::HTTP_OK];
yield [['ROLE_USER'], Response::HTTP_FORBIDDEN];
}

/** @dataProvider dataUserAuthorization */
public function testUserAuthorization(array $roles, int $expectedStatusCode): void
{
$client = self::createClient();
$client->loginUser(new InMemoryUser('emmanuel', 'password', $roles));

$client->request('GET', 'with_security_parameters_collection?name=foo');
$this->assertResponseStatusCodeSame($expectedStatusCode);
}

public function testNoValueParameter(): void
{
$client = self::createClient();
$client->loginUser(new InMemoryUser('emmanuel', 'password', ['ROLE_ADMIN']));

$client->request('GET', 'with_security_parameters_collection?name');
$this->assertResponseIsSuccessful();
}

public function dataSecurityValues(): iterable
{
yield ['secured', Response::HTTP_OK];
yield ['not_the_expected_parameter_value', Response::HTTP_UNAUTHORIZED];
}

/** @dataProvider dataSecurityValues */
public function testSecurityHeaderValues(string $parameterValue, int $expectedStatusCode): void
{
self::createClient()->request('GET', 'with_security_parameters_collection', [
'headers' => [
'auth' => $parameterValue,
],
]);
$this->assertResponseStatusCodeSame($expectedStatusCode);
}

/** @dataProvider dataSecurityValues */
public function testSecurityQueryValues(string $parameterValue, int $expectedStatusCode): void
{
self::createClient()->request('GET', sprintf('with_security_parameters_collection?secret=%s', $parameterValue));
$this->assertResponseStatusCodeSame($expectedStatusCode);
}
}

0 comments on commit 0b985ae

Please sign in to comment.