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:
namespaceApp\Serializer;
useApiPlatform\Core\Serializer\SerializerContextBuilderInterface;
useSymfony\Component\HttpFoundation\Request;
useSymfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
finalclass SerializerContextBuilder implements SerializerContextBuilderInterface
{
/** * @var \ApiPlatform\Core\Serializer\SerializerContextBuilder */private$serializerContextBuilder;
/** * @var AuthorizationCheckerInterface */private$authorizationChecker;
/** * @var SerializerGroupsBuilder */private$serializerGroupsBuilder;
publicfunction__construct(\ApiPlatform\Core\Serializer\SerializerContextBuilder$serializerContextBuilder, AuthorizationCheckerInterface$authorizationChecker, SerializerGroupsBuilder$serializerGroupsBuilder)
{
$this->serializerContextBuilder = $serializerContextBuilder;
$this->authorizationChecker = $authorizationChecker;
$this->serializerGroupsBuilder = $serializerGroupsBuilder;
}
/** * {@inheritdoc} */publicfunctioncreateFromRequest(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;
}
}namespaceApp\Serializer;
/** * Based on API operations and properties. */finalclass SerializerGroupsBuilder
{
/** * @return string[] */publicfunctioncreate(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(staticfunction (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:
namespaceApp\Subscriber\Api;
useApiPlatform\Core\EventListener\EventPriorities;
useSymfony\Component\EventDispatcher\EventSubscriberInterface;
useSymfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent;
useSymfony\Component\HttpKernel\KernelEvents;
useSymfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
useSymfony\Component\Security\Core\Exception\AccessDeniedException;
finalclass ItemAccessSubscriber implements EventSubscriberInterface
{
/** * @var AuthorizationCheckerInterface */private$authorizationChecker;
publicfunction__construct(AuthorizationCheckerInterface$authorizationChecker)
{
$this->authorizationChecker = $authorizationChecker;
}
/** * {@inheritdoc} */publicstaticfunctiongetSubscribedEvents()
{
return [
KernelEvents::VIEW => ['checkAccess', EventPriorities::PRE_SERIALIZE],
];
}
publicfunctioncheckAccess(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()
)) {
thrownewAccessDeniedException(
'You are not granted to access this item resource.'
);
}
}
}namespaceApp\Subscriber\Api;
useApiPlatform\Core\EventListener\EventPriorities;
useSymfony\Component\EventDispatcher\EventSubscriberInterface;
useSymfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent;
useSymfony\Component\HttpKernel\KernelEvents;
useSymfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
useSymfony\Component\Security\Core\Exception\AccessDeniedException;
finalclass CollectionAccessSubscriber implements EventSubscriberInterface
{
/** * @var AuthorizationCheckerInterface */private$authorizationChecker;
publicfunction__construct(AuthorizationCheckerInterface$authorizationChecker)
{
$this->authorizationChecker = $authorizationChecker;
}
/** * {@inheritdoc} */publicstaticfunctiongetSubscribedEvents()
{
return [
KernelEvents::VIEW => ['checkAccess', EventPriorities::PRE_SERIALIZE],
];
}
publicfunctioncheckAccess(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')
)) {
thrownewAccessDeniedException(
'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:
namespaceApp\Security\Authorization\Voter;
useApiPlatform\Core\Exception\ResourceClassNotFoundException;
useApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
useApiPlatform\Core\Metadata\Resource\ResourceMetadata;
useApp\Security\Role\ApiRolesFactory;
useAppBundle\Entity\User;
useSymfony\Component\Security\Core\Authentication\Token\TokenInterface;
useSymfony\Component\Security\Core\Authorization\Voter\Voter;
abstractclass AbstractApiVoter extends Voter
{
/** * @var ResourceMetadataFactoryInterface */private$resourceMetadataFactory;
/** * @var ApiRolesFactory */private$apiRolesFactory;
/** * @var ResourceMetadata */private$resourceMetadata;
/** * @required */finalpublicfunctionsetResourceMetadataFactory(ResourceMetadataFactoryInterface$resourceMetadataFactory): void
{
$this->resourceMetadataFactory = $resourceMetadataFactory;
}
/** * @required */finalpublicfunctionsetApiRolesFactory(ApiRolesFactory$apiRolesFactory): void
{
$this->apiRolesFactory = $apiRolesFactory;
}
finalprotectedfunctiongetApiRolesFactory(): ApiRolesFactory
{
return$this->apiRolesFactory;
}
finalprotectedfunctiongetResourceMetadata(): ResourceMetadata
{
return$this->resourceMetadata;
}
finalprotectedfunctionvoteForAdminAttribute(string$attribute, string$resourceClass, TokenInterface$token): bool
{
$user = $token->getUser();
return$userinstanceof 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} */protectedfunctionsupports($attribute, $subject): bool
{
if (\is_object($subject)) {
$resourceClass = \get_class($subject);
} elseif (\is_string($subject)) {
$resourceClass = $subject;
} else {
returnfalse;
}
try {
$this->resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
returntrue;
} catch (ResourceClassNotFoundException$exception) {
returnfalse;
}
}
}namespaceApp\Security\Authorization\Voter;
useApp\Serializer\SerializerGroupsBuilder;
useDoctrine\Common\Annotations\Reader;
useSymfony\Component\Security\Core\Authentication\Token\TokenInterface;
useSymfony\Component\Serializer\Annotation\Groups;
useSymfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
finalclass ApiCollectionVoter extends AbstractApiVoter
{
/** * @var ClassMetadataFactoryInterface */private$classMetadataFactory;
/** * @var Reader */private$annotationReader;
/** * @var SerializerGroupsBuilder */private$serializerGroupsBuilder;
publicfunction__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} */protectedfunctionsupports($attribute, $subject): bool
{
returnparent::supports($attribute, $subject) && \is_string($subject);
}
/** * {@inheritdoc} */protectedfunctionvoteOnAttribute($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) {
returntrue;
}
return$this->voteForAdminAttribute($attribute, $subject, $token);
}
/** * @param mixed $subject * * @return string[] */privatefunctiongetSerializationGroups($subject): array
{
$groups = [];
foreach ($this->classMetadataFactory->getMetadataFor($subject)
->getReflectionClass()->getMethods() as$reflectionMethod) {
$annotation = $this->annotationReader->getMethodAnnotation($reflectionMethod, Groups::class);
if ($annotationinstanceof Groups) {
$this->extractSerializationGroups($groups, $annotation);
}
}
foreach ($this->classMetadataFactory->getMetadataFor($subject)
->getReflectionClass()->getProperties() as$reflectionProperty) {
$annotation = $this->annotationReader->getPropertyAnnotation($reflectionProperty, Groups::class);
if ($annotationinstanceof Groups) {
$this->extractSerializationGroups($groups, $annotation);
}
}
return\array_values(\array_unique($groups));
}
/** * @param string[] $groups */privatefunctionextractSerializationGroups(array &$groups, Groups$annotation): void
{
foreach ($annotation->getGroups() as$group) {
$groups[] = $group;
}
}
}namespaceApp\Security\Authorization\Voter;
useSymfony\Component\Security\Core\Authentication\Token\TokenInterface;
/** * This voter is useful for admin access. See entity specific voters for the other cases. */finalclass ApiItemVoter extends AbstractApiVoter
{
/** * The subject is the annotated model for item operations. * * {@inheritdoc} */protectedfunctionsupports($attribute, $subject): bool
{
returnparent::supports($attribute, $subject) && \is_object($subject);
}
/** * {@inheritdoc} */protectedfunctionvoteOnAttribute($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:
namespaceApp\Security\Role;
useApiPlatform\Core\Exception\ResourceClassNotFoundException;
useApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
useDoctrine\Common\Inflector\Inflector;
finalclass ApiRolesFactory
{
/** * @var ResourceMetadataFactoryInterface */private$resourceMetadataFactory;
publicfunction__construct(ResourceMetadataFactoryInterface$resourceMetadataFactory)
{
$this->resourceMetadataFactory = $resourceMetadataFactory;
}
/** * @return string[] available roles for the given resources */publicfunctioncreate(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 [];
}
}
publicfunctioncreateRole(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! 😉
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:
So I started to create my custom serializer context builder:
It's a quite simple builder based on the operation name. A
:adminsuffix 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:
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:
The common logic is on
voteForAdminAttribute, building a custom admin role thanks to this factory:The only difference is for collection operation. If the targeted resource class contains at least one serializer group without the
:adminsuffix, it consider it as public and returntrue.And voilà! I don't have to setup anything else except
@ApiResourceand@Groupsannotations! :-)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! 😉