Simple Security Voters


In this video we are going to implement a Symfony Security Voter. In the previous video we covered the logic for restricting access to member content, and really all the Security Voter will do is extract that logic into a separate class.

There is a little more too it than this, but from a high level perspective, that is the plan.

What's really cool about using a Security Voter is that we can inject additional dependencies in via the the Symfony service configuration. This allows us to do pretty much any checks we can think of to get to a 'yes' or 'no' answer.

There's also the concept of Abstaining from the vote, which we will discuss shortly.

Perhaps the nicest thing about Security Voters that I have found so far is that I have not needed to go near the ACL - which is great, as the ACL is probably the most complicated part of Symfony, in my opinion. Note, I do mean the Access Control List (ACL) here, not the access_control list, these are not the same thing!

There are two other immediate benefits to Security Voters.

Firstly, we can re-use the logic. This was semi-possible with Security Annotations by moving the 'guard' logic into the class / entity we were checking.

Secondly, we can write tests for our voting logic. This wasn't at all easy when using the Security Annotation approach. Even if we could test the 'guard' logic by moving it into the associated / calling class, and writing a test against that, it was still very difficult to test an annotation.

Creating a Security Voter

When creating a voter we likely want to give the class the name of the 'thing' we are voting on. In the video we are voting on whether a User should be able to view a given Content entity. Therefore, I would call my voter: ContentVoter.

I generally keep my Voters all together in the YourBundle/Security/Authorization/Voter directory. This is not a defined rule, but it works for me.

We also need the class definition itself, and the Symfony service definition:

# app/config/services.yml
services:
    your_voter_name:
        class: AppBundle\Security\Authorization\Voter\YourVoterClassNameHere
        public: false
        tags:
            - { name: security.voter }

I'm putting the service definition first, as thats usually the thing I forget - and then get a nice blow up error when I try the code in the browser / my test suite.

If you are wondering as to the purpose of public: false, this is to stop you / other developers from grabbing this service from the container, for example maybe trying to use the voter directly inside a controller action.

The class itself is largely the same no matter which 'thing' we are voting on. This is because I always implement the VoterInterface, but there are other ways of creating your Voter classes.

<?php

namespace AppBundle\Security\Authorization\Voter;

use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;

/**
 * Class ContentVoter
 * @package AppBundle\Security\Authorization\Voter
 */
class ContentVoter implements VoterInterface
{
    const VIEW = 'view';

    /**
     * @param string $attribute
     * @return bool
     */
    public function supportsAttribute($attribute)
    {
        return in_array($attribute, array(
            self::VIEW,
        ));
    }

    /**
     * @param string $class
     * @return bool
     */
    public function supportsClass($class)
    {
        $supportedClass = 'AppBundle\Entity\Content';

        return $supportedClass === $class || is_subclass_of($class, $supportedClass);
    }

    /**
     * @param TokenInterface            $token
     * @param null                      $content
     * @param array                     $attributes
     * @return int
     */
    public function vote(TokenInterface $token, $content, array $attributes)
    {
        // check if class of this object is supported by this voter
        if (!$this->supportsClass(get_class($content))) {
            return VoterInterface::ACCESS_ABSTAIN;
        }

        // set the attribute to check against
        $attribute = $attributes[0];

        // check if the given attribute is covered by this voter
        if (!$this->supportsAttribute($attribute)) {
            return VoterInterface::ACCESS_ABSTAIN;
        }

        // update from this point onwards

        // $user = $token->getUser(); 

        // happy path
        if ($something === true) {
            return VoterInterface::ACCESS_GRANTED;
        }

        // sad path
        return VoterInterface::ACCESS_DENIED;
    }
}

There are three key parts - unsurprisingly, given there are three methods on VoterInterface - that we are interested in.

Two of them are really straightforward.

The supportsAttribute() method is all about checking whether this Voter is interested in the 'thing' we are checking. Given a little more context, this makes much more sense.

Imagine we are in a controller action, and we want to check if a User should be able to view a given piece of Content.

<?php

// src/AppBundle/Controller/MembersAreaController.php

namespace AppBundle\Controller;

use AppBundle\Entity\Content;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;

/**
 * @Route("/members-area")
 */
class MembersAreaController extends Controller
{
    /**
     * @Route("/edit/{id}", name="member_edit")
     */
    public function editAction(Request $request, Content $content)
    {
        $this->denyAccessUnlessGranted('view', $content);

        return $this->render('members_area/members.html.twig', [
            'text' => sprintf('Allowed to EDIT id: %d', $content->getId())
        ]);
    }

We are building on the things we learned in the previous video here, using the Symfony Param Convertor to create our Content entity for us based on the {id} in the route.

Once we have the $content, we can use the controller convenience method to check if the currently logged in User has access to view the $content.

In this case, view was the only attribute we configured inside our Voter (const VIEW = 'view';), but you can have as many as you need. You can also use the constant when invoking the denyAccessUnlessGranted method.

This will trigger a vote.

We pass in view, and this is what is checked by supportsAttribute. If our Voter doesn't support the passed in attribute then this Voter returns false and is ignored.

Interestingly, every single Voter will be called for every single Vote.

If we had configured Voters for tags, posts, files, or whatever other entities existed in our project, they would each be called whenever a vote was triggered. This is why supportsClass() exists.

The supportsClass() method will check if the current Voter is configured to vote on the given class.

Again, this relates to $this->denyAccessUnlessGranted('view', $content); - is our ContentVoter an instance of AppBundle\Entity\Content, or a sub class of it? If so, the voter continues, if not, it is ignored.

Largely these two methods are copy / paste > minor tweaks > done.

The really interesting part of a Voter is, unsurprisingly, the Vote method:

    public function vote(TokenInterface $token, $content, array $attributes)
    {
        // check if class of this object is supported by this voter
        if (!$this->supportsClass(get_class($content))) {
            return VoterInterface::ACCESS_ABSTAIN;
        }

        // set the attribute to check against
        $attribute = $attributes[0];

        // check if the given attribute is covered by this voter
        if (!$this->supportsAttribute($attribute)) {
            return VoterInterface::ACCESS_ABSTAIN;
        }

        // update from this point onwards

        // $user = $token->getUser(); 

        // happy path
        if ($something === true) {
            return VoterInterface::ACCESS_GRANTED;
        }

        // sad path
        return VoterInterface::ACCESS_DENIED;
    }

We use our supportsClass() and supportsAttribute() methods, and if one fails its check, then this voter will Abstain from the vote. Abstaining is a fancy way of saying - we are not taking part in this vote. This means this voter will have no influence over the outcome of this vote.

Security Configuration

There are some additional options inside security.yml which can influence the outcome of the vote.

What would happen if every voter Abstained from the vote?

Well, by default the outcome would be a deny.

However, you can change this behaviour:

# app/config/security.yml
security:
    access_decision_manager:
        allow_if_all_abstain:  true # default is false

See the security reference for more options.

Getting to VoterInterface::ACCESS_GRANTED

The core of a voting process is determing whether access should be allowed or denied.

How you get to this point is up to you.

You have access to the currently logged in $user object. You have the passed in object that is being checked. You can also pass in other services, should you need them, via the service definition / constructor arguments.

The logic here can be as simple or as complex as needs be.

The Voter is now much easier to write a test for. You can mock each part as needs be, and then test the allow, deny, and abstain paths as required.

Episodes