ApiPlatform - implement authorization based on apiplatform filters

753 Views Asked by At

I'm using ApiPlatform and Symfony5

I placed a filter on the User entity to sort them by a boolean value of the class named $expose

Use case:

  • For the /users?expose=true route ROLE_USER can get list of every user with filter $expose set to true
  • For the /users/ route ROLE_ADMIN can get list of every user no matter what

Here is my User class:


/**
 * @ApiResource(
 *     attributes={
 *          "normalization_context"={"groups"={"user:read", "user:list"}},
 *          "order"={"somefield.value": "ASC"}
 *     },
 *     collectionOperations={
 *          "get"={
 *              "mehtod"="GET",
 *              "security"="is_granted('LIST', object)",
 *              "normalization_context"={"groups"={"user:list"}},
 *          }
 *     }
 * )
 * @ApiFilter(ExistsFilter::class, properties={"expose"})
 * @ApiFilter(SearchFilter::class, properties={
 *     "somefield.name": "exact"
 * })
 * @ORM\Entity(repositoryClass=UserRepository::class)
 */

I implement my authorization rules through UserVoter:

protected function supports($attribute, $subject): bool
    {
        return parent::supports($attribute, $subject) &&
            ($subject instanceof User ||
                $this->arrayOf($subject, User::class)  ||
                (is_a($subject, Paginator::class) &&
                    $this->arrayOf($subject->getQuery()->getResult(), User::class))
            );
    }

    protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
    {
        /** @var User $user */
        $user = $token->getUser();

        if (!$user instanceof User) {
            return false;
        }

        if ($this->accessDecisionManager->decide($token, [GenericRoles::ROLE_ADMIN])) {
            return true;
        }

        switch ($attribute) {
            case Actions::LIST:
                break;
        }
        return false;
    }

To recover the list of User I recover the paginator object passed through the LIST attribute and make sure the object inside the request result are of type User. This part have been tested and work properly.

Now my issue come from the fact that both those route are essentialy the same to my voter, so my authorization rules implemented through it apply to them both.

What I would like to do would be to tell my voter that both request are different (which I thought I could do as I recover a Paginator object but doesn't seem possible) so I can treat them separately in the same switch case.

So far I havn't found a way to implement it

Is there a way to implement this kind of rules ?

Or is there another way to implement this kind of authorization ?

Thank you!

2

There are 2 best solutions below

0
On

If you can live with ordinary users and admin users using the same request /users/ but getting different results, this docs page describes a way to make the result of GET collection operations depend on the user that is logged in. I adapted it for your question:

<?php
// api/src/Doctrine/CurrentUserExtension.php

namespace App\Doctrine;

use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use App\Entity\Offer;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Security\Core\Security;

final class CurrentUserExtension implements QueryCollectionExtensionInterface
{
    private $security;

    public function __construct(Security $security)
    {
        $this->security = $security;
    }

    public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null): void
    {
        if (User::class !== $resourceClass || $this->security->isGranted('ROLE_ADMIN')) {
            return;
        }

        $rootAlias = $queryBuilder->getRootAliases()[0];
        $queryBuilder->andWhere("$rootAlias.expose = true");
    }
}

BTW, any users that do not have ROLE_ADMIN will get the filtered result, ROLE_USER is not required.

0
On

If you choose to stick with your use case that requires users with ROLE_USER to use /users?expose=true you can make a custom CollectionDataProvider that throws a FilterValidationException:

<?php

namespace App\DataProvider;

use Symfony\Component\Security\Core\Security;
use ApiPlatform\Core\DataProvider\ContextAwareCollectionDataProviderInterface;
use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface;
use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
use ApiPlatform\Core\Exception\FilterValidationException;
use App\Entity\User;

class UserCollectionDataProvider implements ContextAwareCollectionDataProviderInterface, RestrictedDataProviderInterface
{
    /** @var CollectionDataProviderInterface */
    private $dataProvider;

    private $security;

    /**
     * @param CollectionDataProviderInterface $dataProvider The built-in orm CollectionDataProvider of API Platform
     */
    public function __construct(CollectionDataProviderInterface $dataProvider, Security $security)
    {
        $this->dataProvider = $dataProvider;
        $this->security = $security;
    }

    /**
     * {@inheritdoc}
     */
    public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
    {
        return User::class === $resourceClass;
    }

    /** throws FilterValidationException */
    private function validateFilters($context)
    {
    if ($this->security->isGranted('ROLE_ADMIN')) {
            // Allow any filters, including no filters
            return;
        }
        if (!$this->security->isGranted('ROLE_USER')) {
            throw new \LogicException('No use case has been defined for this situation');
        }

        $errorList = [];
        if (!isset($context["filters"]["expose"]) ||
            $context["filters"]["expose"] !== "true" && $context["filters"]["expose"] !== '1'
        ) {
            $errorList[] = 'expose=true filter is required.'
            throw new FilterValidationException($errorList);
        }
    }

    /**
     * {@inheritdoc}
     * @throws FilterValidationException;
     */
    public function getCollection(string $resourceClass, string $operationName = null, array $context = []): array
    {
        $this->validateFilters($context);

        return $this->dataProvider->getCollection($resourceClass, $operationName, $context);
    }

You do need to add the following to api/config/services.yaml:

'App\DataProvider\UserCollectionDataProvider':
    arguments:
        $dataProvider: '@api_platform.doctrine.orm.default.collection_data_provider'

BTW, to filter by a boolean one usually uses a BooleanFilter:

 * @ApiFilter(BooleanFilter::class, properties={"expose"})

This is relevant because users with ROLE_ADMIN may try to filter by expose=false. BTW, If $expose is nullable you need to test what happens with Users that have $expose set to null

WARNING: Be aware that your security will fail silently, allowing all users access to all User entities, if the property $expose is no longer mapped or if the name of the property $expose is changed but in the UserCollectionDataProvider it is not or the Filter spec it is not!