Grant on Permissions, not Roles

Symfony uses a very flexible voter approach to grant access for a user. As this is often based on domain-specific requirements and decisions, the voters that come with Symfony are very basic. I would even argue that it’s better if you not use them, and only rely on custom security voters.

Terminology

In Symfony security, the string ROLE_USER can actually mean a lot of things, let’s first establish a common meaning:

What you pass into isGranted() calls is a security attribute. It can start with ROLE_, but any other text is possible as well.

A user has security roles. These are returned by UserInterface#getRoles() and always start with ROLE_.

The roles setting in your access control is passed into AccessDecisionManager#decide() (which is also what isGranted() uses). While the setting is named “roles”, this is actually a security attribute.

Meet the Role Voters

If you call isGranted() in some way, a small election organized by Symfony Security takes place. Each voter votes GRANT, DENY or ABSTAIN. Voters vote for a specific security attribute, with some optional context (the 2nd argument of isGranted()).

Voters are powerful, but, as said in the intro, often depent on lots of business logic. This is why Symfony cannot provide many voters. To make the security system work, it comes with two very primitive voters: RoleVoter and RoleHierarchyVoter.

These voters activate as soon as the security attribute starts with ROLE_ (e.g. isGranted('ROLE_USER') or isGranted('ROLE_ADMIN')). They check if the current user has this role (or if the role hierarchy contains this role).

What’s wrong With Voting based on Roles?

Voting on a role is very easy: It’s supported by default and gets the job done. Yet, it doesn’t add any flexibility. Also, complex cases result in a lots of code in a controller and often code duplication.

Roles belong to authentication (identification), rather than authorization.

roles groups users

Roles defines the role (or function/task) of this user on the website. Someone might be the “moderator” on this forum, just a regular visitor or some premium plan member. That’s their role. Roles allows your application to group different users together. This makes granting permissions easier. For instance, it’s difficult to describe that users John, Mary and William are allowed to edit all posts. It’s easier to group them as being “the moderators” of this website. You can then grant the permissions “edit all posts” to the “moderators” role.

So rather than using roles as permissions, you relate permissions to specific roles. These permissions are then voted on by a custom voter. This allows you to change permissions throughout your code base easier and keep your controllers thin.

Grant Access for Permissions

For this example, let’s create a voter for a blog post. We want to end up with a call like isGranted("POST_EDIT", $blogPost). The voter should then decide if the current user is allowed to edit the blog post. They should be allowed if any of these is true:

  • If it’s a user, it should be the author of the post
  • If it’s a moderator, it should always be allowed
  • If it’s a senior user, it should be senior for that specific topic

Do you see how we now relate the role of the user to the specific permission of editing a blog post?

Creating the Voter

So the solution is to create your own voter. It’s a PHP class implementing Symfony’s VoterInterface. For easy usage, there is an abstract class named Voter. If you have the MakerBundle installed, you’re lucky:

1
$ bin/console make:voter

Specify if the Voter Supports this Call

The Voter#supports() returns a boolean, depending on whether it wants to join the election for this security attribute and context. In this example, the security attribute should be POST_EDIT and the entity should be instance of BlogPost:

1
2
3
4
5
6
7
8
9
10
11
// ...
class BlogPostVoter extends Voter
{
    protected function supports($attribute, $subject)
    {
        return 'POST_EDIT' === $attribute
            && $subject instanceof \App\Entity\BlogPost;
    }

    // ...
}

Write the Decision Logic

If the voter indicated that it supported this call, Voter#voteOnAttribute() is called. This method implements the permission logic:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// ...
class BlogPostVoter extends Voter
{
    private $decisionManager;

    public function __construct(AccessDecisionManager $decisionManager)
    {
        $this->decisionManager = $decisionManager;
    }

    // ...

    /**
     * @param BlogPost $blogPost
     */
    protected function voteOnAttribute($attribute, $blogPost, TokenInterface $token)
    {
        $user = $token->getUser();
        // if the user is anonymous, do not grant access
        if (!$user instanceof UserInterface) {
            return false;
        }

        // if the user is a moderator, always allow
        if ($this->decisionManager->decide($token, ['ROLE_MODERATOR'])) {
            return true;
        }

        // Allow if the user is senior on this topic
        if ($user->isSeniorIn($blogPost->getTopic()) {
            return true;
        }

        // Allow if the user wrote this post
        if ($blogPost->getAuthor() === $user) {
            return true;
        }

        // Otherwise, deny access
        return false;
    }
}

As you see, in the voter we do call decide() with a role. But that’s fine, this class is meant to relate permissions (POST_EDIT) to a user’s role on the website.

We can now use isGranted('POST_EDIT', $blogPost) in our application to check if the user is allowed to edit the blog post. The access decision manager will call our custom voter to decide on this. If we ever need more complex logic to check, we only have to update the code in the voter.

It’s often a good idea to create one voter per entity. This voter can be extended to also vote on POST_CREATE, POST_DELETE, etc. To check if a Create button should be shown, I recommend passing the FQCN as context as there is no object yet (e.g. is_granted('POST_CREATE', 'App\Entity\BlogPost')).

Make your Voters Dynamic (Access Logic in the Database)

Instead of hardcoding all your permissions in the voters, you can also read information from the database inside your voter. This allows you to persist permissions in the database, which can then be managed by users in an admin panel or the like.

In the most generic way, you can create a Permission entity that specifies which roles are required for a specific security attribute. It may also use Symfony’s ExpressionLanguage component) to check additional conditions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class PermissionVoter implements VoterInterface
{
    // ...

    // this vote() method is the only required method of VoterInterface
    // it should return ACCESS_ABSTAIN (i.e. not supported),
    // ACCESS_GRANTED or ACCESS_DENIED.
    protected function vote(TokenInterface $token, $subject, array $attributes)
    {
        $attribute = $attributes[0];

        // find all stored permissions for this attribute and subject
        $permissions = $this->permissionRepository->findBy([
            'attribute' => $attribute,
            'subject' => $subject,
        ]);

        // do not deny/grant if there is no permission for this
        // attribute and subject
        if (0 === count($permissions)) {
            return self::ACCESS_ABSTAIN;
        }

        foreach ($permissions as $permission) {
            // continue if the role of the user does not match the role
            // of this permission
            if (!$this->decisionManager->decide($token, [$permission->getRole()])) {
                continue;
            }

            // if the permission has an extra expression, verify this is
            // true, otherwise grant access directly
            $expr = $permission->getExpression();
            if ($expr) {
                if ($this->decisionManager->decide($token, [$expr])) {
                    return self::ACCESS_GRANTED;
                }
            } else {
                return self::ACCESS_GRANTED;
            }
        }

        // in any other case, deny access
        return self::ACCESS_DENIED;
    }
}

Take Home’s

  • Roles are identification: They allow your application to group similar types of users
  • Voters relate roles to specific permissions
  • Avoid granting access based on roles in your application, consider writing a custom voter for them