phprolessymfony6

Symfony 6.2 IsGranted with multiple roles


I am blocked with an issue when I want to grant access to users having different roles. As I store some roles within a table context, I created a custom voter for this.

<?php

namespace App\Security\Voter;

use App\Constants\UserRoles;
use App\Entity\Team;
use App\Entity\User;
use App\Repository\UserTeamRepository;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

class TeamVoter extends Voter
{

    public function __construct(
        private UserTeamRepository $userTeamRepository,
    )
    {}

    /**
     * @inheritDoc
     */
    protected function supports(string $attribute, mixed $subject): bool
    {
        if (!in_array($attribute, UserRoles::TEAM_ROLES)) {
            return false;
        }

        return $subject instanceof Team;
    } 

    /**
     * @inheritDoc
     */
    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
    {
        $user = $token->getUser();
        if (!$user instanceof User) {
            return false;
        }
        $team = $subject;

        if (in_array($attribute, UserRoles::TEAM_RELATED_ROLES)) {
            return $this->voteOnTeamRelatedAttribute($attribute, $user, $team);
        }

        return match($attribute) {
            UserRoles::ROLE_TEAM_ADMIN => $this->canEdit($user, $team),
            UserRoles::ROLE_TEAM_OWNER => $this->canDelete($user, $team),
            default => false,
        };
    }

    private function voteOnTeamRelatedAttribute(string $attribute, User $user, Team $team): bool
    {
        $userRoles = $this->userTeamRepository->getUserTeamRoles($user, $team->getExternalUuid());

        if (!$userRoles) {
            return false;
        }

        return match($attribute) {
            UserRoles::ROLE_TEAM_MEMBER_ADMIN => in_array(UserRoles::ROLE_TEAM_MEMBER_ADMIN, $userRoles),
            UserRoles::ROLE_TEAM_MEMBER => in_array(UserRoles::ROLE_TEAM_MEMBER, $userRoles),
            default => false,
        };
    }

    private function canEdit(User $user, Team $team): bool
    {
        if ($this->canDelete($user, $team)) {
            return true;
        }

        return in_array(UserRoles::ROLE_TEAM_ADMIN, $user->getRoles());
    }

    public function canDelete(User $user, Team $team): bool
    {
        return $team->getOwner() === $user || $team->getCreator() === $user;
    }
}

Now I would like that the user with role ROLE_TEAM_ADMIN or the user with role ROLE_TEAM_MEMBER_ADMIN can access the method (note: ROLE_TEAM_ADMIN is more a global role to edit all teams and ROLE_TEAM_MEMBER_ADMIN is more focused on a single team, thus the custom voter checking for the content of the mm table)

And here is the protected method in my controller:

    #[Route('/rest/team/{externalUuid}/member/add', name: 'add_team_member', methods: ["PUT"])]
    #[IsGranted(UserRoles::ROLE_TEAM_ADMIN, subject: 'team'), IsGranted(UserRoles::ROLE_TEAM_MEMBER_ADMIN, subject: 'team')]
    public function addTeamMember(Request $request, Team $team): JsonResponse
    {
        $request = $this->transformJsonBody($request);
    ...

The documentation says that the grant decision strategy can be configured but its default value is set to "affirmative" which means that if one of the voters involved in the granting says it's ok, then the access is granted.

My problem is that this doesn't work as expected and it's rather following the "unanimous" decision mode. I've already browsed stackoverflow with this topic, some people suggest using expressions but those answers are outdated since it's for symfony 5 and the deprecated Sensio security package.

After debugging it I noticed that the Symfony\Component\Security\Core\Authorization\AccessDecisionManager class collects the results (in the collectResults method), looks for the voters and builds its decision on this, but the attributes array comes always with one attribute only.

So I guess that my Annotations are defined in the wrong way but I couldn't find any relevant examples in the doc on how to deal with the Decision manager apparently setting one annotation after another isn't enough.

Does someone have a good way to handle this?


Solution

  • I believe using multiple IsGranted() calls is the same as using the expression $this->isGranted() && $this->isGranted().

    To use OR logic operator rather than AND, have a look at Expression syntax. You can make very complex checks above what the base #[IsGranted(string)] is capable of:

    use Symfony\Component\ExpressionLanguage\Expression;
    
    // ...
    
        #[Route('/rest/team/{externalUuid}/member/add', name: 'add_team_member', methods: ["PUT"])]
        #[IsGranted(
            new Expression(
                'is_granted("' . UserRoles::ROLE_TEAM_ADMIN . '", subject) or ' .
                'is_granted("' . UserRoles::ROLE_TEAM_MEMBER_ADMIN . '", subject)'
            ),
            subject: 'team'
        )]
        public function addTeamMember(Request $request, Team $team): JsonResponse
        {
            $request = $this->transformJsonBody($request);
            // ...
        }