Skip to content

Published September 3, 2026

From global role to voter: when a role hierarchy is no longer enough

A report from the field, no application error, and yet a wrong permissions model. A technical account of moving from role-based access control to contextual authorisation with Symfony voters.

SymfonySecurityVotersAccess controlMigration

During the migration of an internal management application to Symfony 7, the most interesting problem did not come from a test. It came from a site manager, who reported that information belonging to his scope was visible to teams attached to other sites.

No exception, no error log, no alert. The application was doing exactly what it had been told to do. That is what makes this kind of defect expensive: there is nothing to fix in the code, there is a model to rethink.

What a role hierarchy can do

Role-based access control answers one question: who are you?

# config/packages/security.yaml
role_hierarchy:
  ROLE_MANAGER: [ROLE_USER]
  ROLE_ADMIN: [ROLE_MANAGER]
#[IsGranted('ROLE_MANAGER')]
public function collections(): Response
{
    // Every manager sees this screen.
}

It is readable, it is fast, and it works as long as the organisation is flat. The day the same function exists across several sites, the question the code asks is no longer the right one. ROLE_MANAGER says the user is a manager. It does not say what they manage.

The question to ask instead

The right question is not "what is your role?" but "are you allowed on this resource?". It depends on two things at once: the user and the object being accessed. In Symfony, that is exactly a voter's contract.

final class CollectionVoter extends Voter
{
    public const VIEW = 'COLLECTION_VIEW';

    protected function supports(string $attribute, mixed $subject): bool
    {
        return $attribute === self::VIEW && $subject instanceof Collection;
    }

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

        if (!$user instanceof User) {
            return false;
        }

        // The decision depends on the resource, not only on the role held.
        return $user->getSite() === $subject->getSite();
    }
}

The controller barely changes — but it no longer asks the same question:

#[IsGranted(CollectionVoter::VIEW, subject: 'collection')]
public function show(Collection $collection): Response
{
    // Only opens for collections belonging to the user's site.
}

The gain is not syntactic. The authorisation rule leaves the controllers and templates, where it was scattered and copied around, and lives in a class that can be read, tested and changed on its own.

No patch on a version that is going away

The temptation, faced with a report like this one, is to filter the query on that one screen and move on. We did not: the existing version was frozen, its changes limited, and the rework of the permissions model carried by the migration itself.

A filter added by hand on one screen only fixes a symptom, and it copies badly. The same defect reappears on the next screen that queries the same data.

What it changes

A role hierarchy describes an organisation at a point in time. It ages with it. Contextual authorisation describes a rule: you only see what belongs to your scope — and that rule survives reorganisations.

The trigger here was neither a test, nor a code review, nor a static analysis tool: it was someone who uses the software every day and noticed that what was on screen did not match their job. On business software, that is often the best source of bugs.

The non-technical account of this project is published on the ÉSTIAM website.

/ Contact

Software to design, deploy, operate?

Write to me. I reply within 48 h.