Handling permissions with Sylius Resource Bundle
The Sylius Resource bundle does not include permission handling by default, as it can be complex and vary greatly, so it's logical to separate this specific logic from the bundle.
Fortunately, the Resource bundle provides several extension points, allowing you to easily integrate your own permission handling approach.
First, it's important to distinguish between Sylius Stack and Sylius, the e-commerce framework built with the Sylius Stack components. While the first uses a new mechanism to provide data to its internal logic, in Sylius, you have to take a different approach - at least for the time this blog post is written.
Setup
Let's assume we are dealing with an entity called Book
that is known by the Resource Bundle as app.book
. The book entity has a reference to an owner who can edit or delete the books of their own, but can't modify books owned by others.
Sylius
In Sylius, all you need to do is subscribe to a few events that are fired by the ResourceController, e.g:
<?php
declare(strict_types=1);
namespace App\Resource\Security;
use App\Context\ShopUserContextInterface;
use App\Entity\Book\OwnerAware;
use Sylius\Bundle\ResourceBundle\Event\ResourceControllerEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
final readonly class BookOwnerPermissionSubscriber implements EventSubscriberInterface
{
public function __construct(private ShopUserContextInterface $shopUserContext)
{
}
public static function getSubscribedEvents()
{
return [
'app.book.show' => 'isAccessAllowed', // triggered by show Action
'app.book.initialize_update' => 'isAccessAllowed', // triggered by update Action to show the record
'app.book.pre_update' => 'isAccessAllowed', // triggered by update Action before updating the record
'app.book.pre_delete' => 'isAccessAllowed', // triggered by delete Action
];
}
public function isAccessAllowed(ResourceControllerEvent $event): void
{
$subject = $event->getSubject();
$shopUser = $this->shopUserContext->getUser();
if ($shopUser === null) {
return;
}
if ($subject->getOwner() !== $shopUser) {
throw new AccessDeniedException();
}
}
}
The event names follow a structure defined by the Sylius\Bundle\ResourceBundle\Controller\EventDispatcher
class. The event names all follow the same structure, but since the Symfony Event Bus does not allow subscribing to wildcard events, each of the events needs to be configured in the getSubscribedEvents()
method.
In the isAccessAllowed()
, the owner check is done. We fetch the currently logged-in user from our custom ShopUserContext
implementation and then compare the user to the book owner.
In case you are dealing with multiple entities that you want to check the permissions of, using a marker interface, e.g. OwnerAware
, can make sense. Otherwise, you'd need to check the subject's class instance first.
The logic is straightforward, and in case you deal only with a few entities, this approach should work fine. If you need to check the permissions of a lot more entities, you may want to use multiple event subscribers to keep the code manageable.
Sylius Stack
In Sylius Stack - and future versions of Sylius - you can use a combination of a Voter and a custom ReadProvider decorating the default ReadProvider of the Resource Bundle.
First, we need to create a class that implements the Sylius\Resource\State\ProviderInterface
, the class will provide the data to fetch to the caller if the voter allows it:
<?php
declare(strict_types=1);
namespace App\Security\State\Provider;
use Sylius\Resource\Context\Context;
use Sylius\Resource\Metadata\Operation;
use Sylius\Resource\State\ProviderInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
class AuthorizedReadProvider implements ProviderInterface
{
public function __construct(
private readonly ProviderInterface $provider,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
/**
* @return object|array<string,string>|null
*/
public function provide(Operation $operation, Context $context): object|array|null
{
$data = $this->provider->provide($operation, $context);
$attribute = $operation->getShortName();
if ('index' === $attribute || 'create' === $attribute) {
return $data;
}
// for any other operation, let's run the voter check...
if (!$this->authorizationChecker->isGranted($attribute, $data)) {
$exception = new AccessDeniedException();
$exception->setAttributes($attribute);
$exception->setSubject($data);
throw $exception;
}
return $data;
}
}
It's important to note that we make an exception for the index
operation as the data returned is a Grid instance and not the entities that we expect. The grid implementation should be responsible for itself to only return records for the currently logged-in user. How to do that will be covered in another blog post.
Also, we ignore the create
route since we want to allow anyone to save a new book entry in the system.
Registering the service is done in the following way:
App\Security\State\Provider\AuthorizedReadProvider:
class: App\Security\State\Provider\AuthorizedReadProvider
decorates: 'sylius.state_provider.read'
arguments:
- "@.inner"
- "@security.authorization_checker"
The decision whether an entity is editable is taken by a custom Voter. The logic looks like this:
<?php
declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Book\OwnerAware;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class OwnerAwareVoter extends Voter
{
protected function supports(string $attribute, mixed $subject): bool
{
$result = true;
if ('bulk_delete' === $attribute) {
foreach ($subject as $item) {
$result = $result && ($item instanceof OwnerAware);
}
return $result;
}
return \in_array($attribute, ['update', 'delete', 'show'], true) && ($subject instanceof OwnerAware);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if ($subject instanceof OwnerAware) {
if ($user === $subject->getOwner()) {
return true;
}
} elseif (\is_array($subject)) {
$result = true;
foreach ($subject as $item) {
if (!$item instanceof OwnerAware) {
$result = false;
break;
}
if ($user !== $item->getOwner()) {
$result = false;
break;
}
}
return $result;
}
return false;
}
}
In the supports()
method, we both check the type of the action and the object type. Since the reader is used to retrieve any kind of entity, we need to distinguish here between entities we support and entities we don't support.
In case that the supports()
method returns true, the logic in the voteOnAttribute()
method is run to decide if the currently logged-in user is allowed to modify the entity or not. When we are dealing with the bulk_delete
operation, we have to check an array of entities for the matching ownership, which is why the logic is a bit more complex for that specific case.
The voter class also needs to be registered as a service, like this:
App\Security\Voter\OwnerAwareVoter:
class: App\Security\Voter\OwnerAwareVoter
tags:
- { name: 'security.voter' }
As you can see, the Resource Bundle is quite flexible and can be tweaked to match your needs perfectly. Since Sylius and the Sylius components allow the Symfony way as best as possible, modifying the core logic is not too complex.