How to Use Voters to Check User Permissions

How to Use Voters to Check User Permissions

Voters are Symfony’s most powerful way of managing permissions. They allow you to centralize all permission logic, then reuse them in many places.

However, if you don’t reuse permissions or your rules are basic, you can always put that logic directly into your controller instead. Here’s an example how this could look like, if you want to make a route accessible to the “owner” only:

  1. // src/Controller/PostController.php
  2. // ...
  3. // inside your controller action
  4. if ($post->getOwner() !== $this->getUser()) {
  5. throw $this->createAccessDeniedException();
  6. }

In that sense, the following example used throughout this page is a minimal example for voters.

Tip

Take a look at the authorization article for an even deeper understanding on voters.

Here’s how Symfony works with voters: All voters are called each time you use the isGranted() method on Symfony’s authorization checker or call denyAccessUnlessGranted() in a controller (which uses the authorization checker), or by access controls.

Ultimately, Symfony takes the responses from all voters and makes the final decision (to allow or deny access to the resource) according to the strategy defined in the application, which can be: affirmative, consensus or unanimous.

For more information take a look at the section about access decision managers.

The Voter Interface

A custom voter needs to implement Symfony\Component\Security\Core\Authorization\Voter\VoterInterface or extend Symfony\Component\Security\Core\Authorization\Voter\Voter, which makes creating a voter even easier:

  1. use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
  2. use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
  3. abstract class Voter implements VoterInterface
  4. {
  5. abstract protected function supports($attribute, $subject);
  6. abstract protected function voteOnAttribute($attribute, $subject, TokenInterface $token);
  7. }

Setup: Checking for Access in a Controller

Suppose you have a Post object and you need to decide whether or not the current user can edit or view the object. In your controller, you’ll check access with code like this:

  1. // src/Controller/PostController.php
  2. // ...
  3. class PostController extends AbstractController
  4. {
  5. /**
  6. * @Route("/posts/{id}", name="post_show")
  7. */
  8. public function show($id): Response
  9. {
  10. // get a Post object - e.g. query for it
  11. $post = ...;
  12. // check for "view" access: calls all voters
  13. $this->denyAccessUnlessGranted('view', $post);
  14. // ...
  15. }
  16. /**
  17. * @Route("/posts/{id}/edit", name="post_edit")
  18. */
  19. public function edit($id): Response
  20. {
  21. // get a Post object - e.g. query for it
  22. $post = ...;
  23. // check for "edit" access: calls all voters
  24. $this->denyAccessUnlessGranted('edit', $post);
  25. // ...
  26. }
  27. }

The denyAccessUnlessGranted() method (and also the isGranted() method) calls out to the “voter” system. Right now, no voters will vote on whether or not the user can “view” or “edit” a Post. But you can create your own voter that decides this using whatever logic you want.

Creating the custom Voter

Suppose the logic to decide if a user can “view” or “edit” a Post object is pretty complex. For example, a User can always edit or view a Post they created. And if a Post is marked as “public”, anyone can view it. A voter for this situation would look like this:

  1. // src/Security/PostVoter.php
  2. namespace App\Security;
  3. use App\Entity\Post;
  4. use App\Entity\User;
  5. use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
  6. use Symfony\Component\Security\Core\Authorization\Voter\Voter;
  7. class PostVoter extends Voter
  8. {
  9. // these strings are just invented: you can use anything
  10. const VIEW = 'view';
  11. const EDIT = 'edit';
  12. protected function supports($attribute, $subject): bool
  13. {
  14. // if the attribute isn't one we support, return false
  15. if (!in_array($attribute, [self::VIEW, self::EDIT])) {
  16. return false;
  17. }
  18. // only vote on `Post` objects
  19. if (!$subject instanceof Post) {
  20. return false;
  21. }
  22. return true;
  23. }
  24. protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
  25. {
  26. $user = $token->getUser();
  27. if (!$user instanceof User) {
  28. // the user must be logged in; if not, deny access
  29. return false;
  30. }
  31. // you know $subject is a Post object, thanks to `supports()`
  32. /** @var Post $post */
  33. $post = $subject;
  34. switch ($attribute) {
  35. case self::VIEW:
  36. return $this->canView($post, $user);
  37. case self::EDIT:
  38. return $this->canEdit($post, $user);
  39. }
  40. throw new \LogicException('This code should not be reached!');
  41. }
  42. private function canView(Post $post, User $user): bool
  43. {
  44. // if they can edit, they can view
  45. if ($this->canEdit($post, $user)) {
  46. return true;
  47. }
  48. // the Post object could have, for example, a method `isPrivate()`
  49. return !$post->isPrivate();
  50. }
  51. private function canEdit(Post $post, User $user): bool
  52. {
  53. // this assumes that the Post object has a `getOwner()` method
  54. return $user === $post->getOwner();
  55. }
  56. }

That’s it! The voter is done! Next, configure it.

To recap, here’s what’s expected from the two abstract methods:

Voter::supports($attribute, $subject)

When isGranted() (or denyAccessUnlessGranted()) is called, the first argument is passed here as $attribute (e.g. ROLE_USER, edit) and the second argument (if any) is passed as $subject (e.g. null, a Post object). Your job is to determine if your voter should vote on the attribute/subject combination. If you return true, voteOnAttribute() will be called. Otherwise, your voter is done: some other voter should process this. In this example, you return true if the attribute is view or edit and if the object is a Post instance.

voteOnAttribute($attribute, $subject, TokenInterface $token)

If you return true from supports(), then this method is called. Your job is to return true to allow access and false to deny access. The $token can be used to find the current user object (if any). In this example, all of the complex business logic is included to determine access.

Configuring the Voter

To inject the voter into the security layer, you must declare it as a service and tag it with security.voter. But if you’re using the default services.yaml configuration, that’s done automatically for you! When you call isGranted() with view/edit and pass a Post object, your voter will be called and you can control access.

Checking for Roles inside a Voter

What if you want to call isGranted() from inside your voter - e.g. you want to see if the current user has ROLE_SUPER_ADMIN. That’s possible by injecting the Symfony\Component\Security\Core\Security into your voter. You can use this to, for example, always allow access to a user with ROLE_SUPER_ADMIN:

  1. // src/Security/PostVoter.php
  2. // ...
  3. use Symfony\Component\Security\Core\Security;
  4. class PostVoter extends Voter
  5. {
  6. // ...
  7. private $security;
  8. public function __construct(Security $security)
  9. {
  10. $this->security = $security;
  11. }
  12. protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
  13. {
  14. // ...
  15. // ROLE_SUPER_ADMIN can do anything! The power!
  16. if ($this->security->isGranted('ROLE_SUPER_ADMIN')) {
  17. return true;
  18. }
  19. // ... all the normal voter logic
  20. }
  21. }

If you’re using the default services.yaml configuration, you’re done! Symfony will automatically pass the security.helper service when instantiating your voter (thanks to autowiring).

Changing the Access Decision Strategy

Normally, only one voter will vote at any given time (the rest will “abstain”, which means they return false from supports()). But in theory, you could make multiple voters vote for one action and object. For instance, suppose you have one voter that checks if the user is a member of the site and a second one that checks if the user is older than 18.

To handle these cases, the access decision manager uses a “strategy” which you can configure. There are three strategies available:

affirmative (default)

This grants access as soon as there is one voter granting access;

consensus

This grants access if there are more voters granting access than denying. In case of a tie the decision is based on the allow_if_equal_granted_denied config option (defaulting to true);

unanimous

This only grants access if there is no voter denying access.

Regardless the chosen strategy, if all voters abstained from voting, the decision is based on the allow_if_all_abstain config option (which defaults to false).

In the above scenario, both voters should grant access in order to grant access to the user to read the post. In this case, the default strategy is no longer valid and unanimous should be used instead. You can set this in the security configuration:

  • YAML

    1. # config/packages/security.yaml
    2. security:
    3. access_decision_manager:
    4. strategy: unanimous
    5. allow_if_all_abstain: false
  • XML

    1. <!-- config/packages/security.xml -->
    2. <?xml version="1.0" encoding="UTF-8" ?>
    3. <srv:container xmlns="http://symfony.com/schema/dic/security"
    4. xmlns:srv="http://symfony.com/schema/dic/services"
    5. xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    6. xsi:schemaLocation="http://symfony.com/schema/dic/services
    7. https://symfony.com/schema/dic/services/services-1.0.xsd"
    8. >
    9. <config>
    10. <access-decision-manager strategy="unanimous" allow-if-all-abstain="false"/>
    11. </config>
    12. </srv:container>
  • PHP

    1. // config/packages/security.php
    2. $container->loadFromExtension('security', [
    3. 'access_decision_manager' => [
    4. 'strategy' => 'unanimous',
    5. 'allow_if_all_abstain' => false,
    6. ],
    7. ]);

Custom Access Decision Strategy

If none of the built-in strategies fits your use case, define the service option to use a custom service as the Access Decision Manager (your service must implement the Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface):

  • YAML

    1. # config/packages/security.yaml
    2. security:
    3. access_decision_manager:
    4. service: App\Security\MyCustomAccessDecisionManager
    5. # ...
  • XML

    1. <!-- config/packages/security.xml -->
    2. <?xml version="1.0" encoding="UTF-8" ?>
    3. <srv:container xmlns="http://symfony.com/schema/dic/security"
    4. xmlns:srv="http://symfony.com/schema/dic/services"
    5. xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    6. xsi:schemaLocation="http://symfony.com/schema/dic/services
    7. https://symfony.com/schema/dic/services/services-1.0.xsd"
    8. >
    9. <config>
    10. <access-decision-manager
    11. service="App\Security\MyCustomAccessDecisionManager"/>
    12. </config>
    13. </srv:container>
  • PHP

    1. // config/packages/security.php
    2. use App\Security\MyCustomAccessDecisionManager;
    3. $container->loadFromExtension('security', [
    4. 'access_decision_manager' => [
    5. 'service' => MyCustomAccessDecisionManager::class,
    6. // ...
    7. ],
    8. ]);

This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.