Skip to content

Commit

Permalink
refactor: Refactor ReadProvider.php and AccessCheckerProvider.php to …
Browse files Browse the repository at this point in the history
…extract link security into their own providers
  • Loading branch information
KDederichs committed Jan 17, 2024
1 parent c374794 commit 8268630
Show file tree
Hide file tree
Showing 8 changed files with 263 additions and 136 deletions.
86 changes: 86 additions & 0 deletions src/State/Provider/LinkedReadProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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\Exception\InvalidIdentifierException;
use ApiPlatform\Exception\InvalidUriVariableException;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\State\Exception\ProviderNotFoundException;
use ApiPlatform\State\ProviderInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class LinkedReadProvider implements ProviderInterface
{
public function __construct(
private readonly ProviderInterface $decorated,
private readonly ProviderInterface $locator,
private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory
) {
}

public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
$data = $this->decorated->provide($operation, $uriVariables, $context);

if (!$operation instanceof HttpOperation) {
return $data;
}

$request = ($context['request'] ?? null);

if ($operation->getUriVariables()) {
foreach ($operation->getUriVariables() as $key => $uriVariable) {
if (!$uriVariable instanceof Link || !$uriVariable->getSecurity()) {
continue;
}

$relationClass = $uriVariable->getFromClass() ?? $uriVariable->getToClass();

if (!$relationClass) {
continue;
}

$parentOperation = $this->resourceMetadataCollectionFactory
->create($relationClass)
->getOperation($operation->getExtraProperties()['parent_uri_template'] ?? null);
try {
$relation = $this->locator->provide($parentOperation, [$uriVariable->getIdentifiers()[0] => $request?->attributes->all()[$key]], $context);
} catch (ProviderNotFoundException) {
$relation = null;
}

if (!$relation) {
throw new NotFoundHttpException('Relation for link security not found.');
}

try {
$securityObjectName = $uriVariable->getSecurityObjectName();

if (!$securityObjectName) {
$securityObjectName = $uriVariable->getToProperty() ?? $uriVariable->getFromProperty();
}

$request?->attributes->set($securityObjectName, $relation);
} catch (InvalidIdentifierException|InvalidUriVariableException $e) {
throw new NotFoundHttpException('Invalid identifier value or configuration.', $e);
}
}
}

return $data;
}
}
46 changes: 0 additions & 46 deletions src/State/Provider/ReadProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,9 @@

namespace ApiPlatform\State\Provider;

use ApiPlatform\Exception\InvalidIdentifierException;
use ApiPlatform\Exception\InvalidUriVariableException;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Util\CloneTrait;
use ApiPlatform\Serializer\SerializerContextBuilderInterface;
use ApiPlatform\State\Exception\ProviderNotFoundException;
Expand All @@ -40,7 +36,6 @@ final class ReadProvider implements ProviderInterface

public function __construct(
private readonly ProviderInterface $provider,
private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory,
private readonly ?SerializerContextBuilderInterface $serializerContextBuilder = null,
) {
}
Expand Down Expand Up @@ -90,47 +85,6 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
throw new NotFoundHttpException('Not Found');
}

if ($operation->getUriVariables()) {
foreach ($operation->getUriVariables() as $key => $uriVariable) {
if (!$uriVariable instanceof Link || !$uriVariable->getSecurity()) {
continue;
}

$relationClass = $uriVariable->getFromClass() ?? $uriVariable->getToClass();

if (!$relationClass) {
continue;
}

$parentOperation = $this->resourceMetadataCollectionFactory
->create($relationClass)
->getOperation($operation->getExtraProperties()['parent_uri_template'] ?? null);
try {
$relation = $this->provider->provide($parentOperation, [$uriVariable->getIdentifiers()[0] => $request?->attributes->all()[$key]], $context);
} catch (ProviderNotFoundException) {
$relation = null;
}
if (!$relation) {
throw new NotFoundHttpException('Not Found');
}

try {
$securityObjectName = $uriVariable->getSecurityObjectName();

if (!$securityObjectName) {
$securityObjectName = $uriVariable->getToProperty() ?? $uriVariable->getFromProperty();
}

if (!$securityObjectName) {
continue;
}
$request?->attributes->set($securityObjectName, $relation);
} catch (InvalidIdentifierException|InvalidUriVariableException $e) {
throw new NotFoundHttpException('Invalid identifier value or configuration.', $e);
}
}
}

$request?->attributes->set('data', $data);
$request?->attributes->set('previous_data', $this->clone($data));

Expand Down
5 changes: 5 additions & 0 deletions src/Symfony/Bundle/Resources/config/security.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
<argument type="service" id="api_platform.security.resource_access_checker" />
</service>

<service id="api_platform.state_provider.access_checker_linked" class="ApiPlatform\Symfony\Security\State\LinkAccessCheckerProvider" decorates="api_platform.state_provider.read">
<argument type="service" id="api_platform.state_provider.access_checker_linked.inner" />
<argument type="service" id="api_platform.security.resource_access_checker" />
</service>

<service id="api_platform.state_provider.access_checker.post_deserialize" class="ApiPlatform\Symfony\Security\State\AccessCheckerProvider" decorates="api_platform.state_provider.deserialize">
<argument type="service" id="api_platform.state_provider.access_checker.post_deserialize.inner" />
<argument type="service" id="api_platform.security.resource_access_checker" />
Expand Down
7 changes: 6 additions & 1 deletion src/Symfony/Bundle/Resources/config/state.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,15 @@

<service id="api_platform.state_provider.read" class="ApiPlatform\State\Provider\ReadProvider" decorates="api_platform.state_provider.main" decoration-priority="500">
<argument type="service" id="api_platform.state_provider.read.inner" />
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
<argument type="service" id="api_platform.serializer.context_builder" />
</service>

<service id="api_platform.state_provider.read_link" class="ApiPlatform\State\Provider\LinkedReadProvider" decorates="api_platform.state_provider.read" decoration-priority="499">
<argument type="service" id="api_platform.state_provider.read_link.inner" />
<argument type="service" id="api_platform.state_provider.locator" />
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
</service>

<service id="api_platform.state_provider.deserialize" class="ApiPlatform\State\Provider\DeserializeProvider" decorates="api_platform.state_provider.main" decoration-priority="300">
<argument type="service" id="api_platform.state_provider.deserialize.inner" />
<argument type="service" id="api_platform.serializer" />
Expand Down
27 changes: 4 additions & 23 deletions src/Symfony/Security/State/AccessCheckerProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;
use ApiPlatform\Metadata\GraphQl\QueryCollection;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use ApiPlatform\Symfony\Security\Exception\AccessDeniedException;
Expand Down Expand Up @@ -52,6 +51,9 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
}

$body = $this->decorated->provide($operation, $uriVariables, $context);
if (null === $isGranted) {
return $body;
}

// On a GraphQl QueryCollection we want to perform security stage only on the top-level query
if ($operation instanceof QueryCollection && null !== ($context['source'] ?? null)) {
Expand All @@ -73,31 +75,10 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
];
}

if ($isGranted && !$this->resourceAccessChecker->isGranted($operation->getClass(), $isGranted, $resourceAccessCheckerContext)) {
if (!$this->resourceAccessChecker->isGranted($operation->getClass(), $isGranted, $resourceAccessCheckerContext)) {
$operation instanceof GraphQlOperation ? throw new AccessDeniedHttpException($message ?? 'Access Denied.') : throw new AccessDeniedException($message ?? 'Access Denied.');
}

if ($operation instanceof HttpOperation && $operation->getUriVariables()) {
foreach ($operation->getUriVariables() as $uriVariable) {
if (!$uriVariable instanceof Link || !$uriVariable->getSecurity()) {
continue;
}

$targetResource = $uriVariable->getFromClass() ?? $uriVariable->getToClass();

if (!$targetResource) {
continue;
}

// We need to add all attributes here again because we do not know the name of the security object
$resourceAccessCheckerContext += $request->attributes->all();

if (!$this->resourceAccessChecker->isGranted($targetResource, $uriVariable->getSecurity(), $resourceAccessCheckerContext)) {
throw new AccessDeniedException($uriVariable->getSecurityMessage() ?? 'Access Denied.');
}
}
}

return $body;
}
}
73 changes: 73 additions & 0 deletions src/Symfony/Security/State/LinkAccessCheckerProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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\Symfony\Security\State;

use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use ApiPlatform\Symfony\Security\Exception\AccessDeniedException;
use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface;

class LinkAccessCheckerProvider implements ProviderInterface
{
public function __construct(
private readonly ProviderInterface $decorated,
private readonly ResourceAccessCheckerInterface $resourceAccessChecker
) {
}

public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
$request = ($context['request'] ?? null);

$data = $this->decorated->provide($operation, $uriVariables, $context);

if ($operation instanceof HttpOperation && $operation->getUriVariables()) {
foreach ($operation->getUriVariables() as $uriVariable) {
if (!$uriVariable instanceof Link || !$uriVariable->getSecurity()) {
continue;
}

$targetResource = $uriVariable->getFromClass() ?? $uriVariable->getToClass();

if (!$targetResource) {
continue;
}

$propertyName = $uriVariable->getToProperty() ?? $uriVariable->getFromProperty();
$securityObjectName = $uriVariable->getSecurityObjectName();

if (!$securityObjectName) {
$securityObjectName = $propertyName;
}

if (!$securityObjectName) {
continue;
}

$resourceAccessCheckerContext = [
$securityObjectName => $request?->attributes->get($securityObjectName),
'request' => $request,
];

if (!$this->resourceAccessChecker->isGranted($targetResource, $uriVariable->getSecurity(), $resourceAccessCheckerContext)) {
throw new AccessDeniedException($uriVariable->getSecurityMessage() ?? 'Access Denied.');
}
}
}

return $data;
}
}
66 changes: 0 additions & 66 deletions tests/Symfony/Security/State/AccessCheckerProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,12 @@
namespace ApiPlatform\Tests\Symfony\Security\State;

use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\GraphQl\Query;
use ApiPlatform\Metadata\Link;
use ApiPlatform\State\ProviderInterface;
use ApiPlatform\Symfony\Security\Exception\AccessDeniedException;
use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface;
use ApiPlatform\Symfony\Security\State\AccessCheckerProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\ParameterBag;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

class AccessCheckerProviderTest extends TestCase
Expand Down Expand Up @@ -93,66 +89,4 @@ public function testCheckAccessDeniedWithGraphQl(): void
$accessChecker = new AccessCheckerProvider($decorated, $resourceAccessChecker);
$accessChecker->provide($operation, [], []);
}

public function testIsGrantedLink(): void
{
$obj = new \stdClass();
$barObj = new \stdClass();
$operation = new GetCollection(uriVariables: [
'barId' => new Link(toProperty: 'bar', fromClass: 'Bar', security: 'is_granted("some_voter", "bar")'),
], class: 'Foo');
$decorated = $this->createMock(ProviderInterface::class);
$decorated->method('provide')->willReturn($obj);
$request = $this->createMock(Request::class);
$parameterBag = new ParameterBag();
$request->attributes = $parameterBag;
$request->attributes->set('bar', $barObj);
$resourceAccessChecker = $this->createMock(ResourceAccessCheckerInterface::class);
$resourceAccessChecker->expects($this->once())->method('isGranted')->with('Bar', 'is_granted("some_voter", "bar")', ['object' => $obj, 'previous_object' => null, 'request' => $request, 'bar' => $barObj])->willReturn(true);
$accessChecker = new AccessCheckerProvider($decorated, $resourceAccessChecker);
$accessChecker->provide($operation, [], ['request' => $request]);
}

public function testIsNotGrantedLink(): void
{
$this->expectException(AccessDeniedException::class);

$obj = new \stdClass();
$barObj = new \stdClass();
$operation = new GetCollection(uriVariables: [
'barId' => new Link(toProperty: 'bar', fromClass: 'Bar', security: 'is_granted("some_voter", "bar")'),
], class: 'Foo');
$decorated = $this->createMock(ProviderInterface::class);
$decorated->method('provide')->willReturn($obj);
$request = $this->createMock(Request::class);
$parameterBag = new ParameterBag();
$request->attributes = $parameterBag;
$request->attributes->set('bar', $barObj);
$resourceAccessChecker = $this->createMock(ResourceAccessCheckerInterface::class);
$resourceAccessChecker->expects($this->once())->method('isGranted')->with('Bar', 'is_granted("some_voter", "bar")', ['object' => $obj, 'previous_object' => null, 'request' => $request, 'bar' => $barObj])->willReturn(false);
$accessChecker = new AccessCheckerProvider($decorated, $resourceAccessChecker);
$accessChecker->provide($operation, [], ['request' => $request]);
}

public function testSecurityMessageLink(): void
{
$this->expectException(AccessDeniedException::class);
$this->expectExceptionMessage('You are not admin.');

$obj = new \stdClass();
$barObj = new \stdClass();
$operation = new GetCollection(uriVariables: [
'barId' => new Link(toProperty: 'bar', fromClass: 'Bar', security: 'is_granted("some_voter", "bar")', securityMessage: 'You are not admin.'),
], class: 'Foo');
$decorated = $this->createMock(ProviderInterface::class);
$decorated->method('provide')->willReturn($obj);
$request = $this->createMock(Request::class);
$parameterBag = new ParameterBag();
$request->attributes = $parameterBag;
$request->attributes->set('bar', $barObj);
$resourceAccessChecker = $this->createMock(ResourceAccessCheckerInterface::class);
$resourceAccessChecker->expects($this->once())->method('isGranted')->with('Bar', 'is_granted("some_voter", "bar")', ['object' => $obj, 'previous_object' => null, 'request' => $request, 'bar' => $barObj])->willReturn(false);
$accessChecker = new AccessCheckerProvider($decorated, $resourceAccessChecker);
$accessChecker->provide($operation, [], ['request' => $request]);
}
}
Loading

0 comments on commit 8268630

Please sign in to comment.