Locastic Jan 05

Administrate your administrators with Sylius RBAC

4 min read –

Not long ago, we went through a common, yet complicated demand on a Sylius shop : « Contextualize access rights according to the admin user logged in »

At the moment, there are 2 solutions to do so in the Sylius Ecosystem.

  • The one that comes with SyliusPlus and that I yet have to try.
  • And the one from BitBag Plugin, that I also have not tried yet.

But since that for the purpose of the project, we needed a very basic feature, and we did not want to bother understanding and overriding any of those two existing (and paid) solutions, we decided to use a custom one.

Concept

We chose a role-based approach, with a grant/deny strategy. As a matter of fact, it is quite similar to the one that Symfony offers out of the box with the security component. So the first step was to define the data structure. We opted in for YAML defined roles, because at first, the roles should only be manageable via the code, but can then be deported to the admin panel. And since YAML is easily convertible to PHP array, it sounded like the top choice for this task.

Data structure

parameters:
    roles_accesses:
        {roleName}:
            strategy: 'denyAllExcept'
            redirect_if_not_granted: 'sylius_admin_dashboard'
            allow_partials: true
            exceptions:
                routes:
                    - 'sylius_admin_dashboard'
                    - 'sylius_admin_taxon_*'
                resources:
                    - 'sylius.product.index'
            abstain_for:
                routes:
                    - 'sylius_admin_admin_user_update'
                resources:
                    - 'sylius.admin_user.update'

Here is the structure we are using. It is a simple Symfony parameter, since we went with a denyAllExcept strategy, all the routes or resources in exceptions are allowed. We also decided to allow all partial routes at once since a lot of admin pages rely on those (product autocomplete ie.). Regarding the abstain_for part, we will get back to that later.

Get the file and deny access

Let’s now use this new parameter in our app. And to do so, we will use a leftover from old Sylius versions that were never removed, fortunately.

In each and every ResourceController there is a check in the form of $this->isGrantedOr403(). And if we dig a little bit more, it is calling a DisabledAuthorizationChecker that always return true. Obviously, this is not useful at all like that, but the main point is that Sylius is giving us a way to develop that, without much effort.

We have to create a new class that implements this AuthorizationCheckerInterface from SyliusResourceBundle.

But things would be too easy if that was it, wouldn’t they?

<?php

declare(strict_types=1);

namespace App\RightsManagement\Controller;

use App\RightsManagement\Exception\AdminUserAccessDeniedException;
use App\RightsManagement\Provider\ResourceProviderInterface;
use App\RightsManagement\Security\AdminAuthorizationCheckerInterface;
use Sylius\Bundle\ResourceBundle\Controller\AuthorizationCheckerInterface;
use Sylius\Bundle\ResourceBundle\Controller\RequestConfiguration;

final class AuthorizationChecker implements AuthorizationCheckerInterface
{
    private AdminAuthorizationCheckerInterface $adminAuthChecker;

    private ResourceProviderInterface $resourceProvider;

    public function __construct(
        AdminAuthorizationCheckerInterface $adminAuthChecker,
        ResourceProviderInterface $resourceProvider
    ) {
        $this->adminAuthChecker = $adminAuthChecker;
        $this->resourceProvider = $resourceProvider;
    }

    public function isGranted(RequestConfiguration $configuration, string $permission): bool
    {
        $request = $configuration->getRequest();
        $routeName = $request->attributes->get('_route');

        $resource = $this->resourceProvider->getFromPermissionAndRequestConfiguration($permission, $configuration);

        if (!$this->adminAuthChecker->isGranted($routeName, $permission, $resource)) {
            throw new AdminUserAccessDeniedException('You shall not pass');
        }

        return true;
    }
}

So here, we are performing a few basic, but needed tasks for our feature.

First of all, we fetch the route name. It will be useful later to perform checks on it.

Secondly, we fetch the resource concerned by the action. Without going into details, it will fetch the resource in case of update, delete or show and null for other cases. The third and final action is to perform a check against a Voter with permission, the resource, and the route.

Voting is a duty

Regarding the voter implementation its role will be to first filter the strictly granted or denied routes. You know, those in exceptions . Will they be routes, or resources.

    public function isGranted(?string $routeName, ?string $permission, ?ResourceInterface $resource = null): bool
    {
        if (null !== $routeName) {
            $routeVoter = new RouteVoter($this->adminUserRightsProvider);
            $routeDecision = $routeVoter->vote($this->tokenStorage->getToken(), $routeName, [RouteVoter::ACCESS_ROUTE]);
            if (VoterInterface::ACCESS_DENIED === $routeDecision) {
                return false;
            } elseif (VoterInterface::ACCESS_GRANTED === $routeDecision) {
                return true;
            }
        }

        if (null !== $permission) {
            // Determine requested access type
            $accessType = $this->getResourceAccessType($permission);
            if (null === $accessType) {
                return true;
            }

            $resourceVoter = new ResourceVoter($this->adminUserRightsProvider);
            $resourceDecision = $resourceVoter->vote($this->tokenStorage->getToken(), $permission, [$accessType]);
            if (VoterInterface::ACCESS_DENIED === $resourceDecision) {
                return false;
            } elseif (VoterInterface::ACCESS_GRANTED === $resourceDecision) {
                return true;
            }

            // Try to call Symfony voter
            return $this->authorizationChecker->isGranted($permission, $resource);
        }

        return true;
    }

    private function getResourceAccessType(string $permission): ?string
    {
        $type = null;
        if (\preg_match('/\.([a-z]+)$/', $permission, $matches)) {
            $type = $matches[1];
        }

        switch ($type) {
            case ResourceActions::INDEX:
                return ResourceVoter::LIST_RESOURCES;
            case ResourceActions::UPDATE:
                return ResourceVoter::UPDATE_RESOURCE;
            case ResourceActions::CREATE:
                return ResourceVoter::CREATE_RESOURCE;
            case ResourceActions::SHOW:
                return ResourceVoter::SHOW_RESOURCE;
            case ResourceActions::DELETE:
                return ResourceVoter::DELETE_RESOURCE;
            default:
                return null;
        }
    }

Here is an example of how to do it, but of course there are many other ways. Once more, // TODO

Once this filter is done, if we are still there, and no boolean has been returned, we are left with the abstain_for. Meaning another voter should now be asked to vote for this access.

And here comes the SymfonyVoter. The idea here will be to be able to grant access to a resource according to some specifications. Like an admin can edit his profile, but can not edit other admin.

And since we managed to fetch the resource earlier, it is easy to compare according to custom logic. Here is an example.

<?php

declare(strict_types=1);

namespace App\RightsManagement\Security\Voter\User;

use App\Entity\User\AdminUser;
use LogicException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use function in_array;

final class AdminUserVoter extends Voter
{
    public const UPDATE = 'sylius.admin_user.update';

    /**
     * @param string $attribute
     * @param mixed  $subject
     *
     * @return bool
     */
    protected function supports($attribute, $subject): bool
    {
        if (!in_array($attribute, [self::UPDATE])) {
            return false;
        }

        if (!$subject instanceof AdminUser) {
            return false;
        }

        return true;
    }

    protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
    {
        $loggedInUser = $token->getUser();

        if (!$loggedInUser instanceof AdminUser) {
            return false;
        }

        if ($loggedInUser->getGroup() === AdminUser::GROUP_SUPER_ADMIN) {
            return true;
        }

        /** @var AdminUser $subjectUser */
        $subjectUser = $subject;
        switch ($attribute) {
            case self::UPDATE:
                return $this->canUpdate($subjectUser, $loggedInUser);
        }

        throw new LogicException('This code should not be reached.');
    }

    private function canUpdate(AdminUser $subject, AdminUser $loggedInUser): bool
    {
        // Non admin user can only update themselves
        return $subject->getId() === $loggedInUser->getId();
    }
}

And there you go! That means that our new role can perform taxon management, can list products, and edit his profile. Everything else will be forbidden.

Starting from that, you can easily do any custom mechanism you want inside your Sylius App.

What about you?

How are you managing roles inside your application? Feel free to share the article on social media and I’ll join the discussion!