From b488366901635dc665f1bb95a4a059aaa30cefd3 Mon Sep 17 00:00:00 2001 From: Kai Dederichs Date: Fri, 19 Jan 2024 09:03:15 +0100 Subject: [PATCH] feat(symfony): Link security (#5290) * [Link] Start Link Security * feat(provider): Auto Resolve Get Operation and Parameters * chore(CS): fix CS * feat(tests): Add DenyAccessListener tests * feat(tests): Add link security behat tests * fix(test): fix mongodb document configuration * fix(readlistner): fix error 500 on not existing entity * feat(linksecurity): expand functionality to cover all combinations of to and from property and add optional object name * feat(linksecurity): add more tests * chore: fix cs * chore: phpstan fix * fix: Move logic to refactored, now used, classes * fix: refactor unit tests * fix: backport for legacy event system as well * Revert "fix: backport for legacy event system as well" This reverts commit 16f14c836e19635888e5430a7dff9ecf8280c1d3. * refactor: Refactor ReadProvider.php and AccessCheckerProvider.php to extract link security into their own providers * mark providers final, disable feature by default --- behat.yml.dist | 2 +- features/authorization/deny.feature | 82 +++++++++++++++++ src/Metadata/Link.php | 53 ++++++++++- .../ApiPlatformExtension.php | 8 ++ .../DependencyInjection/Configuration.php | 1 + .../Bundle/Resources/config/link_security.xml | 20 ++++ .../State/LinkAccessCheckerProvider.php | 80 ++++++++++++++++ .../Security/State/LinkedReadProvider.php | 91 +++++++++++++++++++ tests/Behat/DoctrineContext.php | 11 +++ .../Document/RelatedLinkedDummy.php | 74 +++++++++++++++ .../TestBundle/Document/SecuredDummy.php | 8 ++ .../TestBundle/Entity/RelatedLinkedDummy.php | 78 ++++++++++++++++ .../TestBundle/Entity/SecuredDummy.php | 8 ++ tests/Fixtures/app/config/config_common.yml | 1 + .../DependencyInjection/ConfigurationTest.php | 1 + .../State/LinkAccessCheckerProviderTest.php | 89 ++++++++++++++++++ 16 files changed, 605 insertions(+), 2 deletions(-) create mode 100644 src/Symfony/Bundle/Resources/config/link_security.xml create mode 100644 src/Symfony/Security/State/LinkAccessCheckerProvider.php create mode 100644 src/Symfony/Security/State/LinkedReadProvider.php create mode 100644 tests/Fixtures/TestBundle/Document/RelatedLinkedDummy.php create mode 100644 tests/Fixtures/TestBundle/Entity/RelatedLinkedDummy.php create mode 100644 tests/Symfony/Security/State/LinkAccessCheckerProviderTest.php diff --git a/behat.yml.dist b/behat.yml.dist index 5bb9d10b525..85d96bf6747 100644 --- a/behat.yml.dist +++ b/behat.yml.dist @@ -203,7 +203,7 @@ legacy: - 'Behat\MinkExtension\Context\MinkContext' - 'behatch:context:rest' filters: - tags: '~@postgres&&~@mongodb&&~@elasticsearch' + tags: '~@postgres&&~@mongodb&&~@elasticsearch&&~@link_security' extensions: 'FriendsOfBehat\SymfonyExtension': bootstrap: 'tests/Fixtures/app/bootstrap.php' diff --git a/features/authorization/deny.feature b/features/authorization/deny.feature index 91227419479..192e6e03ac3 100644 --- a/features/authorization/deny.feature +++ b/features/authorization/deny.feature @@ -211,6 +211,88 @@ Feature: Authorization checking And the response should contain "ownerOnlyProperty" And the JSON node "ownerOnlyProperty" should be equal to the string "updated" + @link_security + Scenario: An non existing entity should return Not found + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send a "GET" request to "/secured_dummies/40000/to_from" + Then the response status code should be 404 + + @link_security + Scenario: An user can get related linked dummies for an secured dummy they own + Given there are 1 SecuredDummy objects owned by dunglas with related dummies + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send a "GET" request to "/secured_dummies/4/to_from" + Then the response status code should be 200 + And the response should contain "securedDummy" + And the JSON node "hydra:member[0].id" should be equal to 1 + + @link_security + Scenario: I define a custom name of the security object + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send a "GET" request to "/secured_dummies/4/with_name" + Then the response status code should be 200 + And the response should contain "securedDummy" + And the JSON node "hydra:member[0].id" should be equal to 1 + + @link_security + Scenario: I define a from from link + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send a "GET" request to "/related_linked_dummies/1/from_from" + Then the response status code should be 200 + And the response should contain "id" + And the JSON node "hydra:member[0].id" should be equal to 4 + + @link_security + Scenario: I define multiple links with security + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send a "GET" request to "/secured_dummies/4/related/1" + Then the response status code should be 200 + And the response should contain "id" + And the JSON node "hydra:member[0].id" should be equal to 1 + + @link_security + Scenario: An user can not get related linked dummies for an secured dummy they do not own + Given there are 1 SecuredDummy objects owned by someone with related dummies + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send a "GET" request to "/secured_dummies/5/to_from" + Then the response status code should be 403 + + @link_security + Scenario: I define a custom name of the security object + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send a "GET" request to "/secured_dummies/5/with_name" + Then the response status code should be 403 + + @link_security + Scenario: I define a from from link + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send a "GET" request to "/related_linked_dummies/2/from_from" + Then the response status code should be 403 + + @link_security + Scenario: I define multiple links with security + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send a "GET" request to "/secured_dummies/5/related/2" + Then the response status code should be 403 + Scenario: A user retrieves a resource with an admin only viewable property When I add "Accept" header equal to "application/json" And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" diff --git a/src/Metadata/Link.php b/src/Metadata/Link.php index 926341b7447..75e6260d4b1 100644 --- a/src/Metadata/Link.php +++ b/src/Metadata/Link.php @@ -16,7 +16,7 @@ #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::TARGET_PARAMETER)] final class Link { - public function __construct(private ?string $parameterName = null, private ?string $fromProperty = null, private ?string $toProperty = null, private ?string $fromClass = null, private ?string $toClass = null, private ?array $identifiers = null, private ?bool $compositeIdentifier = null, private ?string $expandedValue = null) + public function __construct(private ?string $parameterName = null, private ?string $fromProperty = null, private ?string $toProperty = null, private ?string $fromClass = null, private ?string $toClass = null, private ?array $identifiers = null, private ?bool $compositeIdentifier = null, private ?string $expandedValue = null, private ?string $security = null, private ?string $securityMessage = null, private ?string $securityObjectName = null) { // For the inverse property shortcut if ($this->parameterName && class_exists($this->parameterName)) { @@ -128,6 +128,45 @@ public function withExpandedValue(string $expandedValue): self return $self; } + 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; + } + + public function withSecurityObjectName(?string $securityObjectName): self + { + $self = clone $this; + $self->securityObjectName = $securityObjectName; + + return $self; + } + public function withLink(self $link): self { $self = clone $this; @@ -164,6 +203,18 @@ public function withLink(self $link): self $self->expandedValue = $expandedValue; } + if (!$self->getSecurity() && ($security = $link->getSecurity())) { + $self->security = $security; + } + + if (!$self->getSecurityMessage() && ($securityMessage = $link->getSecurityMessage())) { + $self->securityMessage = $securityMessage; + } + + if (!$self->getSecurityObjectName() && ($securityObjectName = $link->getSecurityObjectName())) { + $self->securityObjectName = $securityObjectName; + } + return $self; } } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 134fe401aba..97af318774e 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -164,6 +164,7 @@ public function load(array $configs, ContainerBuilder $container): void $this->registerSecurityConfiguration($container, $config, $loader); $this->registerMakerConfiguration($container, $config, $loader); $this->registerArgumentResolverConfiguration($loader); + $this->registerLinkSecurityConfiguration($loader, $config); $container->registerForAutoconfiguration(FilterInterface::class) ->addTag('api_platform.filter'); @@ -892,4 +893,11 @@ private function registerInflectorConfiguration(array $config): void Inflector::keepLegacyInflector(false); } } + + private function registerLinkSecurityConfiguration(XmlFileLoader $loader, array $config): void + { + if ($config['enable_link_security']) { + $loader->load('link_security.xml'); + } + } } diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index cdb3af6bbcd..33e4b9f5070 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -111,6 +111,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->booleanNode('enable_docs')->defaultTrue()->info('Enable the docs')->end() ->booleanNode('enable_profiler')->defaultTrue()->info('Enable the data collector and the WebProfilerBundle integration.')->end() ->booleanNode('keep_legacy_inflector')->defaultTrue()->info('Keep doctrine/inflector instead of symfony/string to generate plurals for routes.')->end() + ->booleanNode('enable_link_security')->defaultFalse()->info('Enable security for Links (sub resources)')->end() ->arrayNode('collection') ->addDefaultsIfNotSet() ->children() diff --git a/src/Symfony/Bundle/Resources/config/link_security.xml b/src/Symfony/Bundle/Resources/config/link_security.xml new file mode 100644 index 00000000000..a33e45f3262 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/link_security.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Security/State/LinkAccessCheckerProvider.php b/src/Symfony/Security/State/LinkAccessCheckerProvider.php new file mode 100644 index 00000000000..1d4b49a9af2 --- /dev/null +++ b/src/Symfony/Security/State/LinkAccessCheckerProvider.php @@ -0,0 +1,80 @@ + + * + * 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; + +/** + * Checks the individual parts of the linked resource for access rights. + * + * @experimental + */ +final 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 = [ + 'object' => $data, + 'previous_object' => $request?->attributes->get('previous_data'), + $securityObjectName => $request?->attributes->get($securityObjectName), + 'request' => $request, + ]; + + if (!$this->resourceAccessChecker->isGranted($targetResource, $uriVariable->getSecurity(), $resourceAccessCheckerContext)) { + throw new AccessDeniedException($uriVariable->getSecurityMessage() ?? 'Access Denied.'); + } + } + } + + return $data; + } +} diff --git a/src/Symfony/Security/State/LinkedReadProvider.php b/src/Symfony/Security/State/LinkedReadProvider.php new file mode 100644 index 00000000000..7c5eaf7b1b5 --- /dev/null +++ b/src/Symfony/Security/State/LinkedReadProvider.php @@ -0,0 +1,91 @@ + + * + * 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\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; + +/** + * Checks if the linked resources have security attributes and prepares them for access checking. + * + * @experimental + */ +final 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; + } +} diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php index ee87d2f05c2..c11e34cf15e 100644 --- a/tests/Behat/DoctrineContext.php +++ b/tests/Behat/DoctrineContext.php @@ -85,6 +85,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Document\PropertyUriTemplateOneToOneRelation as PropertyUriTemplateOneToOneRelationDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\Question as QuestionDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedLinkedDummy as RelatedLinkedDummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedOwnedDummy as RelatedOwnedDummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedOwningDummy as RelatedOwningDummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedSecuredDummy as RelatedSecuredDummyDocument; @@ -180,6 +181,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Question; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RamseyUuidDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedLinkedDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedOwnedDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedOwningDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedSecuredDummy; @@ -1197,12 +1199,16 @@ public function thereAreSecuredDummyObjectsOwnedByWithRelatedDummies(int $nb, st $publicRelatedSecuredDummy = $this->buildRelatedSecureDummy(); $this->manager->persist($publicRelatedSecuredDummy); + $relatedLinkedDummy = $this->buildRelatedLinkedDummy(); + $this->manager->persist($relatedLinkedDummy); + $securedDummy->addRelatedDummy($relatedDummy); $securedDummy->setRelatedDummy($relatedDummy); $securedDummy->addRelatedSecuredDummy($relatedSecuredDummy); $securedDummy->setRelatedSecuredDummy($relatedSecuredDummy); $securedDummy->addPublicRelatedSecuredDummy($publicRelatedSecuredDummy); $securedDummy->setPublicRelatedSecuredDummy($publicRelatedSecuredDummy); + $relatedLinkedDummy->setSecuredDummy($securedDummy); $this->manager->persist($securedDummy); } @@ -2499,6 +2505,11 @@ private function buildRelatedToDummyFriend(): RelatedToDummyFriend|RelatedToDumm return $this->isOrm() ? new RelatedToDummyFriend() : new RelatedToDummyFriendDocument(); } + private function buildRelatedLinkedDummy(): RelatedLinkedDummy|RelatedLinkedDummyDocument + { + return $this->isOrm() ? new RelatedLinkedDummy() : new RelatedLinkedDummyDocument(); + } + private function buildRelationEmbedder(): RelationEmbedder|RelationEmbedderDocument { return $this->isOrm() ? new RelationEmbedder() : new RelationEmbedderDocument(); diff --git a/tests/Fixtures/TestBundle/Document/RelatedLinkedDummy.php b/tests/Fixtures/TestBundle/Document/RelatedLinkedDummy.php new file mode 100644 index 00000000000..e45799155a6 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/RelatedLinkedDummy.php @@ -0,0 +1,74 @@ + + * + * 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\Document; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ApiResource()] +#[ApiResource( + uriTemplate: '/secured_dummies/{securedDummyId}/to_from', + operations: [new GetCollection()], + uriVariables: [ + 'securedDummyId' => new Link(toProperty: 'securedDummy', fromClass: SecuredDummy::class, security: "is_granted('ROLE_USER') and securedDummy.getOwner() == user"), + ] +)] +#[ApiResource( + uriTemplate: '/secured_dummies/{securedDummyId}/with_name', + operations: [new GetCollection()], + uriVariables: [ + 'securedDummyId' => new Link(toProperty: 'securedDummy', fromClass: SecuredDummy::class, security: "is_granted('ROLE_USER') and testObj.getOwner() == user", securityObjectName: 'testObj'), + ] +)] +#[ApiResource( + uriTemplate: '/secured_dummies/{securedDummyId}/related/{id}', + operations: [new GetCollection()], + uriVariables: [ + 'securedDummyId' => new Link(toProperty: 'securedDummy', fromClass: SecuredDummy::class, security: "is_granted('ROLE_USER') and securedDummy.getOwner() == user"), + 'id' => new Link(fromClass: RelatedLinkedDummy::class, security: "is_granted('ROLE_USER') and testObj.getSecuredDummy().getOwner() == user", securityObjectName: 'testObj'), + ] +)] +#[ODM\Document] +class RelatedLinkedDummy +{ + #[ApiProperty(writable: false)] + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private $id; + + #[ODM\ReferenceOne(targetDocument: SecuredDummy::class, storeAs: 'id')] + private SecuredDummy $securedDummy; + + public function getId() + { + return $this->id; + } + + public function setId($id): void + { + $this->id = $id; + } + + public function getSecuredDummy(): SecuredDummy + { + return $this->securedDummy; + } + + public function setSecuredDummy(SecuredDummy $securedDummy): void + { + $this->securedDummy = $securedDummy; + } +} diff --git a/tests/Fixtures/TestBundle/Document/SecuredDummy.php b/tests/Fixtures/TestBundle/Document/SecuredDummy.php index b58aee73a30..8ecef6e7c10 100644 --- a/tests/Fixtures/TestBundle/Document/SecuredDummy.php +++ b/tests/Fixtures/TestBundle/Document/SecuredDummy.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; use Doctrine\Common\Collections\ArrayCollection; @@ -34,6 +35,13 @@ * @author Alan Poulain */ #[ApiResource(operations: [new Get(security: 'is_granted(\'ROLE_USER\') and object.getOwner() == user'), new Put(securityPostDenormalize: 'is_granted(\'ROLE_USER\') and previous_object.getOwner() == user', extraProperties: ['standard_put' => false]), new GetCollection(security: 'is_granted(\'ROLE_USER\') or is_granted(\'ROLE_ADMIN\')'), new GetCollection(uriTemplate: 'custom_data_provider_generator', security: 'is_granted(\'ROLE_USER\')'), new Post(security: 'is_granted(\'ROLE_ADMIN\')')], graphQlOperations: [new Query(name: 'item_query', security: 'is_granted(\'ROLE_ADMIN\') or (is_granted(\'ROLE_USER\') and object.getOwner() == user)'), new QueryCollection(name: 'collection_query', security: 'is_granted(\'ROLE_ADMIN\')'), new Mutation(name: 'delete'), new Mutation(name: 'update', securityPostDenormalize: 'is_granted(\'ROLE_USER\') and previous_object.getOwner() == user'), new Mutation(name: 'create', security: 'is_granted(\'ROLE_ADMIN\')', securityMessage: 'Only admins can create a secured dummy.')], security: 'is_granted(\'ROLE_USER\')')] +#[ApiResource( + uriTemplate: '/related_linked_dummies/{relatedDummyId}/from_from', + operations: [new GetCollection()], + uriVariables: [ + 'relatedDummyId' => new Link(fromProperty: 'securedDummy', fromClass: RelatedLinkedDummy::class, security: "is_granted('ROLE_USER') and relatedDummy.getSecuredDummy().getOwner() == user", securityObjectName: 'relatedDummy'), + ] +)] #[ODM\Document] class SecuredDummy { diff --git a/tests/Fixtures/TestBundle/Entity/RelatedLinkedDummy.php b/tests/Fixtures/TestBundle/Entity/RelatedLinkedDummy.php new file mode 100644 index 00000000000..6715a842f79 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/RelatedLinkedDummy.php @@ -0,0 +1,78 @@ + + * + * 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\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use Doctrine\ORM\Mapping as ORM; +use Doctrine\ORM\Mapping\Entity; + +#[ApiResource()] +#[ApiResource( + uriTemplate: '/secured_dummies/{securedDummyId}/to_from', + operations: [new GetCollection()], + uriVariables: [ + 'securedDummyId' => new Link(toProperty: 'securedDummy', fromClass: SecuredDummy::class, security: "is_granted('ROLE_USER') and securedDummy.getOwner() == user"), + ] +)] +#[ApiResource( + uriTemplate: '/secured_dummies/{securedDummyId}/with_name', + operations: [new GetCollection()], + uriVariables: [ + 'securedDummyId' => new Link(toProperty: 'securedDummy', fromClass: SecuredDummy::class, security: "is_granted('ROLE_USER') and testObj.getOwner() == user", securityObjectName: 'testObj'), + ] +)] +#[ApiResource( + uriTemplate: '/secured_dummies/{securedDummyId}/related/{id}', + operations: [new GetCollection()], + uriVariables: [ + 'securedDummyId' => new Link(toProperty: 'securedDummy', fromClass: SecuredDummy::class, security: "is_granted('ROLE_USER') and securedDummy.getOwner() == user"), + 'id' => new Link(fromClass: RelatedLinkedDummy::class, security: "is_granted('ROLE_USER') and testObj.getSecuredDummy().getOwner() == user", securityObjectName: 'testObj'), + ] +)] +#[Entity] +class RelatedLinkedDummy +{ + /** + * @var int + */ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private $id; + + #[ORM\ManyToOne(targetEntity: SecuredDummy::class)] + private ?SecuredDummy $securedDummy = null; + + public function getId() + { + return $this->id; + } + + public function setId($id): void + { + $this->id = $id; + } + + public function getSecuredDummy(): ?SecuredDummy + { + return $this->securedDummy; + } + + public function setSecuredDummy(?SecuredDummy $securedDummy): void + { + $this->securedDummy = $securedDummy; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/SecuredDummy.php b/tests/Fixtures/TestBundle/Entity/SecuredDummy.php index 8a08548d5a0..daa191452cd 100644 --- a/tests/Fixtures/TestBundle/Entity/SecuredDummy.php +++ b/tests/Fixtures/TestBundle/Entity/SecuredDummy.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; use Doctrine\Common\Collections\ArrayCollection; @@ -48,6 +49,13 @@ ], security: 'is_granted(\'ROLE_USER\')' )] +#[ApiResource( + uriTemplate: '/related_linked_dummies/{relatedDummyId}/from_from', + operations: [new GetCollection()], + uriVariables: [ + 'relatedDummyId' => new Link(fromProperty: 'securedDummy', fromClass: RelatedLinkedDummy::class, security: "is_granted('ROLE_USER') and relatedDummy.getSecuredDummy().getOwner() == user", securityObjectName: 'relatedDummy'), + ] +)] #[ORM\Entity] class SecuredDummy { diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 4981e21ff3f..49db87d92cd 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -78,6 +78,7 @@ api_platform: invalidation: enabled: true keep_legacy_inflector: false + enable_link_security: true # see also defaults in AppKernel doctrine_mongodb_odm: false diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index aa728a7e5fe..432c3fc367d 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -227,6 +227,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'keep_legacy_inflector' => true, 'event_listeners_backward_compatibility_layer' => true, 'handle_symfony_errors' => false, + 'enable_link_security' => false, ], $config); } diff --git a/tests/Symfony/Security/State/LinkAccessCheckerProviderTest.php b/tests/Symfony/Security/State/LinkAccessCheckerProviderTest.php new file mode 100644 index 00000000000..e185e4cfd44 --- /dev/null +++ b/tests/Symfony/Security/State/LinkAccessCheckerProviderTest.php @@ -0,0 +1,89 @@ + + * + * 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\Symfony\Security\State; + +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Symfony\Security\Exception\AccessDeniedException; +use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface; +use ApiPlatform\Symfony\Security\State\LinkAccessCheckerProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\ParameterBag; +use Symfony\Component\HttpFoundation\Request; + +class LinkAccessCheckerProviderTest extends TestCase +{ + 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 LinkAccessCheckerProvider($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 LinkAccessCheckerProvider($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 LinkAccessCheckerProvider($decorated, $resourceAccessChecker); + $accessChecker->provide($operation, [], ['request' => $request]); + } +}