Description
I am integrating api-platform on an existing big project with nearly a hundred of entities.
First of all, you did a great works guys! I have nothing to do for the CRUD system, no controller, nada. Thanks to this, I can focus on my custom logic only.
The project does not have any public access and most of the resource accesses depends of the customer or the admin right (support, sailor, IT guys etc...).
I read the security part of the documentation, but it does not feed my need, especially because of the manual role setup on each resource. Remember, hundred of entities... :-)
Here is the rules I want to setup:
- User can list and read resources he belongs to.
- User can create a resources if the create action is considered as "public".
- Admin may access to anything (read/write) only if he has the role corresponding to the resource or if the resource creation is "public".
So I started to create my custom serializer context builder:
namespace App\Serializer;
use ApiPlatform\Core\Serializer\SerializerContextBuilderInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class SerializerContextBuilder implements SerializerContextBuilderInterface
{
/**
* @var \ApiPlatform\Core\Serializer\SerializerContextBuilder
*/
private $serializerContextBuilder;
/**
* @var AuthorizationCheckerInterface
*/
private $authorizationChecker;
/**
* @var SerializerGroupsBuilder
*/
private $serializerGroupsBuilder;
public function __construct(\ApiPlatform\Core\Serializer\SerializerContextBuilder $serializerContextBuilder, AuthorizationCheckerInterface $authorizationChecker, SerializerGroupsBuilder $serializerGroupsBuilder)
{
$this->serializerContextBuilder = $serializerContextBuilder;
$this->authorizationChecker = $authorizationChecker;
$this->serializerGroupsBuilder = $serializerGroupsBuilder;
}
/**
* {@inheritdoc}
*/
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
{
$context = $this->serializerContextBuilder->createFromRequest($request, $normalization, $extractedAttributes);
if (!isset($context['groups'])) {
$context['groups'] = [];
}
$collection = isset($extractedAttributes['collection_operation_name']);
$context['groups'] = \array_merge(
$context['groups'],
$this->serializerGroupsBuilder->create(
$extractedAttributes[$collection ? 'collection_operation_name' : 'item_operation_name'],
$collection,
$normalization,
$this->authorizationChecker->isGranted('ROLE_ADMIN')
)
);
return $context;
}
}
namespace App\Serializer;
/**
* Based on API operations and properties.
*/
final class SerializerGroupsBuilder
{
/**
* @return string[]
*/
public function create(string $operation, bool $collection, bool $normalization, bool $withAdminGroups = false): array
{
// If property is allowed on collection, it must be allowed on single item too.
$groups = ["{$operation}s"];
if (!$collection || !$normalization) {
$groups[] = $operation;
}
if ($withAdminGroups) {
$groups = \array_merge($groups, \array_map(static function (string $group) {
return "{$group}:admin";
}, $groups));
}
return $groups;
}
}
It's a quite simple builder based on the operation name. A :admin
suffix is added if the user has admin permissions.
Then, since I don't want to use the security annotation like the doc, I have to setup two listener to check item and collection access:
namespace App\Subscriber\Api;
use ApiPlatform\Core\EventListener\EventPriorities;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
final class ItemAccessSubscriber implements EventSubscriberInterface
{
/**
* @var AuthorizationCheckerInterface
*/
private $authorizationChecker;
public function __construct(AuthorizationCheckerInterface $authorizationChecker)
{
$this->authorizationChecker = $authorizationChecker;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents()
{
return [
KernelEvents::VIEW => ['checkAccess', EventPriorities::PRE_SERIALIZE],
];
}
public function checkAccess(GetResponseForControllerResultEvent $event): void
{
$request = $event->getRequest();
if (!$request->attributes->has('_api_item_operation_name')) {
return;
}
if (!$this->authorizationChecker->isGranted(
$request->attributes->get('_api_item_operation_name'),
$event->getControllerResult()
)) {
throw new AccessDeniedException(
'You are not granted to access this item resource.'
);
}
}
}
namespace App\Subscriber\Api;
use ApiPlatform\Core\EventListener\EventPriorities;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
final class CollectionAccessSubscriber implements EventSubscriberInterface
{
/**
* @var AuthorizationCheckerInterface
*/
private $authorizationChecker;
public function __construct(AuthorizationCheckerInterface $authorizationChecker)
{
$this->authorizationChecker = $authorizationChecker;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents()
{
return [
KernelEvents::VIEW => ['checkAccess', EventPriorities::PRE_SERIALIZE],
];
}
public function checkAccess(GetResponseForControllerResultEvent $event): void
{
$request = $event->getRequest();
if (!$request->attributes->has('_api_collection_operation_name')) {
return;
}
if (!$this->authorizationChecker->isGranted(
$request->attributes->get('_api_collection_operation_name'),
$request->attributes->get('_api_resource_class')
)) {
throw new AccessDeniedException(
'You are not granted to access this collection resource.'
);
}
}
}
The item subscriber with give the related object to the authorization checker, the collection subscriber will give the resource class. It will be used to differentiate the action type.
The "classic user" voter is a simple voter. It will check if the entity belongs to the user to grant or deny access to it.
About collection operation and admin item access, I have two voters using an abstract one:
namespace App\Security\Authorization\Voter;
use ApiPlatform\Core\Exception\ResourceClassNotFoundException;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
use App\Security\Role\ApiRolesFactory;
use AppBundle\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
abstract class AbstractApiVoter extends Voter
{
/**
* @var ResourceMetadataFactoryInterface
*/
private $resourceMetadataFactory;
/**
* @var ApiRolesFactory
*/
private $apiRolesFactory;
/**
* @var ResourceMetadata
*/
private $resourceMetadata;
/**
* @required
*/
final public function setResourceMetadataFactory(ResourceMetadataFactoryInterface $resourceMetadataFactory): void
{
$this->resourceMetadataFactory = $resourceMetadataFactory;
}
/**
* @required
*/
final public function setApiRolesFactory(ApiRolesFactory $apiRolesFactory): void
{
$this->apiRolesFactory = $apiRolesFactory;
}
final protected function getApiRolesFactory(): ApiRolesFactory
{
return $this->apiRolesFactory;
}
final protected function getResourceMetadata(): ResourceMetadata
{
return $this->resourceMetadata;
}
final protected function voteForAdminAttribute(string $attribute, string $resourceClass, TokenInterface $token): bool
{
$user = $token->getUser();
return $user instanceof User
&&
(
$user->hasRole('ROLE_SUPER_ADMIN')
||
(
$user->hasRole('ROLE_ADMIN')
&& $user->hasRole($this->getApiRolesFactory()->createRole($resourceClass, $attribute))
)
);
}
/**
* The subject must be using ApiResource annotation.
*
* {@inheritdoc}
*/
protected function supports($attribute, $subject): bool
{
if (\is_object($subject)) {
$resourceClass = \get_class($subject);
} elseif (\is_string($subject)) {
$resourceClass = $subject;
} else {
return false;
}
try {
$this->resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
return true;
} catch (ResourceClassNotFoundException $exception) {
return false;
}
}
}
namespace App\Security\Authorization\Voter;
use App\Serializer\SerializerGroupsBuilder;
use Doctrine\Common\Annotations\Reader;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
final class ApiCollectionVoter extends AbstractApiVoter
{
/**
* @var ClassMetadataFactoryInterface
*/
private $classMetadataFactory;
/**
* @var Reader
*/
private $annotationReader;
/**
* @var SerializerGroupsBuilder
*/
private $serializerGroupsBuilder;
public function __construct(ClassMetadataFactoryInterface $classMetadataFactory, Reader $annotationReader, SerializerGroupsBuilder $serializerGroupsBuilder)
{
$this->classMetadataFactory = $classMetadataFactory;
$this->annotationReader = $annotationReader;
$this->serializerGroupsBuilder = $serializerGroupsBuilder;
}
/**
* The subject is the FCQN for collection operations.
*
* {@inheritdoc}
*/
protected function supports($attribute, $subject): bool
{
return parent::supports($attribute, $subject) && \is_string($subject);
}
/**
* {@inheritdoc}
*/
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
/*
* Considers public access to the collection operation
* if default serialization groups (e.g. not admin)
* are present on the API resource.
*/
if (\count(\array_intersect(
$this->getSerializationGroups($subject),
$this->serializerGroupsBuilder->create(
$attribute,
true,
'get' === $attribute
)
)) > 0) {
return true;
}
return $this->voteForAdminAttribute($attribute, $subject, $token);
}
/**
* @param mixed $subject
*
* @return string[]
*/
private function getSerializationGroups($subject): array
{
$groups = [];
foreach ($this->classMetadataFactory->getMetadataFor($subject)
->getReflectionClass()->getMethods() as $reflectionMethod) {
$annotation = $this->annotationReader->getMethodAnnotation($reflectionMethod, Groups::class);
if ($annotation instanceof Groups) {
$this->extractSerializationGroups($groups, $annotation);
}
}
foreach ($this->classMetadataFactory->getMetadataFor($subject)
->getReflectionClass()->getProperties() as $reflectionProperty) {
$annotation = $this->annotationReader->getPropertyAnnotation($reflectionProperty, Groups::class);
if ($annotation instanceof Groups) {
$this->extractSerializationGroups($groups, $annotation);
}
}
return \array_values(\array_unique($groups));
}
/**
* @param string[] $groups
*/
private function extractSerializationGroups(array &$groups, Groups $annotation): void
{
foreach ($annotation->getGroups() as $group) {
$groups[] = $group;
}
}
}
namespace App\Security\Authorization\Voter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
/**
* This voter is useful for admin access. See entity specific voters for the other cases.
*/
final class ApiItemVoter extends AbstractApiVoter
{
/**
* The subject is the annotated model for item operations.
*
* {@inheritdoc}
*/
protected function supports($attribute, $subject): bool
{
return parent::supports($attribute, $subject) && \is_object($subject);
}
/**
* {@inheritdoc}
*/
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
return $this->voteForAdminAttribute($attribute, \get_class($subject), $token);
}
}
The common logic is on voteForAdminAttribute
, building a custom admin role thanks to this factory:
namespace App\Security\Role;
use ApiPlatform\Core\Exception\ResourceClassNotFoundException;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use Doctrine\Common\Inflector\Inflector;
final class ApiRolesFactory
{
/**
* @var ResourceMetadataFactoryInterface
*/
private $resourceMetadataFactory;
public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory)
{
$this->resourceMetadataFactory = $resourceMetadataFactory;
}
/**
* @return string[] available roles for the given resources
*/
public function create(string $resourceClass): array
{
try {
$itemsOperations = $this->resourceMetadataFactory->create($resourceClass)->getItemOperations();
$collectionOperations = $this->resourceMetadataFactory->create($resourceClass)->getCollectionOperations();
return \array_unique(
\array_map(function (string $itemOperationName) use ($resourceClass) {
return $this->createRole($resourceClass, $itemOperationName);
}, \array_keys(
\array_merge(
$itemsOperations ?? [],
$collectionOperations ?? []
)
))
);
} catch (ResourceClassNotFoundException $exception) {
return [];
}
}
public function createRole(string $resourceClass, string $operation): string
{
return \mb_strtoupper(
'ROLE_ADMIN_'
.Inflector::tableize(
\array_reverse(\explode('\\', $resourceClass))[0]
)
.'_'
.$operation
);
}
}
The only difference is for collection operation. If the targeted resource class contains at least one serializer group without the :admin
suffix, it consider it as public and return true
.
And voilà! I don't have to setup anything else except @ApiResource
and @Groups
annotations! :-)
I'm now looking about the documentation generator part.
What do you think about the logic? I agree the need is maybe quite specific, but maybe you will have some inspiration. May api-platform integrate some automatism like that based on role and group serialization? Or maybe it's more a place for an "extra-bundle"?
Thanks for reading! 😉