Symfony Security Voters (free cookies!)

Symfony Security Voters (free cookies!)

See also

Voters have been updated in Symfony 2.8! Check out our updated tutorial about them: The new Voter Class.

Hey guys! It’s getting a little colder in Michigan, Leanna and I are doing a little bit of baking, and baking makes me think of security. Specifically the kind of security that says: “no you can’t eat my cookie because I baked it”. And it actually has use inside of our applications because a lot of times we need to figure out whether the current user has access to edit, delete or view something. It’s because of this we’ve cooked up a delicious little application which is going to show you one of my favorite and most underutilized features in Symfony: security voters.

Today’s Application: DeliciousCookie

So I’ll login using username ryan password cookie and basically we only have one page in this app which shows us all of these cookies that Ryan and Leanna have baked. Each of those has a “nom” button which allows me to eat the cookie, shows me a nice message and deletes it from the database. Really high-tech stuff.

The application is pretty straight forward: we have an AppBundle of course and inside of there we have a single entity called DeliciousCookie. The most important thing about DeliciousCookie is that there’s a bakerUsername property which stores who actually baked this cookie. To keep this application simple I don’t have a User entity so I’m just using a username string there:

// src/AppBundle/Entity/DeliciousCookie.php
// ...

/** @ORM\Entity */
class DeliciousCookie
{
    // ...

    /**
     * @ORM\Column()
     */
    private $flavor;

    /**
     * @ORM\Column(name="baker_username")
     */
    private $bakerUsername;

    // ...
}

Right now, anybody can eat any cookie no matter who baked it. But our goal is to make it so that you can only eat cookies that you’ve baked. The CookieController holds the page that actually lists the cookies and then there’s one POST-only endpoint which handles actually deleting the cookie in the database and setting that nice flash message:

// src/AppBundle/Controller/CookieController.php
// ...

class CookieController extends Controller
{
    /** @Route("/cookies", name="cookie_list") */
    public function indexAction()
    {
        $cookies = $this->getDoctrine()
            ->getRepository('AppBundle:DeliciousCookie')
            ->findAll();

        return $this->render('Cookie/index.html.twig', array(
            'cookies' => $cookies,
        ));
    }

    /**
     * @Route("/cookies/nom/{id}", name="cookie_nom")
     * @Method("POST")
     */
    public function nomAction(Request $request, $id)
    {
        $em = $this->getDoctrine()->getManager();
        $cookie = $em->getRepository('AppBundle:DeliciousCookie')
            ->find($id);

        // ...

        $em->remove($cookie);
        $em->flush();

        // some flash-setting stuff...

        return $this->redirect($url);
    }
}

The only other interesting thing is security.yml. We have two hard-coded users: ryan and leanna, and I also have an access_control which requires login for everything under /cookies, which is why we had to login before we saw our cookie list. We take cookie security very seriously:

# app/config/security.yml
security:
    # ...

    providers:
        in_memory:
            memory:
                users:
                    ryan:  { password: cookie, roles: 'ROLE_COOKIE_ENJOYER' }
                    leanna: { password: cookie, roles: 'ROLE_COOKIE_MONSTER' }


    firewalls:
        default:
            pattern: ^/
            anonymous: ~
            form_login: ~
            logout: ~

    access_control:
        - { path: ^/cookies, roles: IS_AUTHENTICATED_FULLY }

Preventing Access: The Easy Way

Preventing me from eating a cookie baked by someone else is actually pretty simple. And what we should do first is just put the logic into our controller. So I’ll do that here: if the baker’s username does not equal the current user’s username, we’re going to throw that AccessDeniedException and say: “Hey you didn’t bake this!”:

// src/AppBundle/Controller/CookieController.php
// ...

public function nomAction(Request $request, $id)
{
    $em = $this->getDoctrine()->getManager();
    $cookie = $em->getRepository('AppBundle:DeliciousCookie')
        ->find($id);

    // ...

    if ($cookie->getBakerUsername() != $this->getUser()->getUsername()) {
        throw $this->createAccessDeniedException(
            'You did not bake this delicious cookie!'
        );
    }
    // ...
}

Now if we try to eat one of Leanna’s cookies she catches us with a nice clear message. And of course in the production environment, this would be your 403 error page.

Tip

See Error Pages for how to customize your 404, 403 and 500 error pages in production.

So what’s the problem with this? The problem is that we also need to go into our Twig template and repeat the logic there:

{# app/Resources/views/Cookie/index.html.twig #}
{# ... #}

{% for cookie in cookies %}
    {# ... #}

    {% if cookie.bakerUsername == app.user.username %}
        <form action="{{ path('cookie_nom', {'id': cookie.id}) }}" method="POST">
            <button type="submit" class="btn">NOM! <i class="glyphicon glyphicon-cutlery"></i></button>
        </form>
    {% endif %}

    {# ... #}
{% endfor %}

And when it comes to security logic, especially security logic that protects cookies, we don’t want to repeat it across your application. If you change something later and forget to update part of your security, you’re going to have a big problem. But for now, I’ll do it manually and we can see that the nom button hides or shows based on which cookies I actually baked.

Creating a Voter

So the goal of a voter is to allow us to centralize that logic so we only have it in one spot. I’ll create a Security directory which is purely for organization and then put a CookieVoter inside of it. I’m using Symfony 2.6 for this project, which comes with a fantastic new AbstractVoter class which I’m going to use. If you’re using Symfony 2.5 or lower, you can actually find this class on the internet and just use it in your project today. Just update the namespace to match your project and then extend it. This class doesn’t have any external dependencies so it’s going to work just fine in whatever Symfony version you have.

So I’ll extend it and then use a really nice feature in PHPstorm to tell me the three abstract methods that I need to fill in:

// src/AppBundle/Security/CookieVoter.php
namespace AppBundle\Security;

use Symfony\Component\Security\Core\Authorization\Voter\AbstractVoter;
use Symfony\Component\Security\Core\User\UserInterface;

class CookieVoter extends AbstractVoter
{
    protected function getSupportedClasses()
    {
        // todo
    }

    protected function getSupportedAttributes()
    {
        // todo
    }

    protected function isGranted($attribute, $object, $user = null)
    {
        // todo
    }
}

But What does a Voter Do?

So let me back up because I haven’t actually told you what these voters do. First let me show you how I want our code to look when we’re finished. Instead of doing the logic manually I’m going to use the isGranted function, pass it a string: NOM which is something I’m making up – you’ll see why it’s important in a second – and then pass the $cookie object as the second argument to isGranted:

// src/AppBundle/Controller/CookieController.php
// ...

public function nomAction(Request $request, $id)
{
    $em = $this->getDoctrine()->getManager();
    $cookie = $em->getRepository('AppBundle:DeliciousCookie')
        ->find($id);

    // ...

    if (!$this->isGranted('NOM', $cookie)) {
        throw $this->createAccessDeniedException(
            'You did not bake this delicious cookie!'
        );
    }
    // ...
}

The isGranted shortcut is new to 2.6 but all it does is go out to the security.context service and call isGranted on it. So this is exactly what you’re using in earlier projects. If you don’t have the shortcut method just go out to the security.context service manually.

Behind the scenes, whenever you use the isGranted function, Symfony calls out to a bunch of voters and asks each of them if they can figure out whether or not we should have access. For example, whenever you pass ROLE_SOMETHING to `isGranted` like `ROLE_USER, there’s a RoleVoter` class which tries to figure out whether the current user has whatever role you’re asking about.

What most people don’t realize is that you can invent these strings. So in this case I’m just inventing NOM and we’re going to add a new voter into that system that says: “Hey Symfony! Whenever the NOM attribute is passed to isGranted, call me!” To get that to work we just need to fill in the getSupportedClasses and the getSupportedAttributes functions.

Filling in CookieVoter

First, in getSupportedClasses, were going to return the DeliciousCookie class string:

// src/AppBundle/Security/CookieVoter.php
// ...

protected function getSupportedClasses()
{
    return array('AppBundle\Entity\DeliciousCookie');
}

This tells Symfony that when we pass a DeliciousCookie object to isGranted, our voter should be called. We’ll do the same thing in getSupportedAttributes and we’ll return an array with the NOM string:

// src/AppBundle/Security/CookieVoter.php
// ...

protected function getSupportedAttributes()
{
    return array('NOM');
}

This tells Symfony that when we pass NOM to isGranted that our voter should be called. Whenever both of these functions return true, the `isGranted` function at the bottom of this class is going to be called.

For now I’ll just use the glorious var_dump to print the attribute object and user and I’m going to put a die after that:

// src/AppBundle/Security/CookieVoter.php
// ...

protected function isGranted($attribute, $object, $user = null)
{
    var_dump($attribute, $object, $user);die;
}

Registering and Tagging your Voter

At this point, other than the crazy debug code I have at the bottom, our voter class is ready to go. But Symfony is not going to automatically find it. To tell Symfony about our new voter we’re going to need to register it as a service and give it a special tag.

I have an app/config/services.yml file which I’m importing from my config.yml file, so we’ll put the service there:

# app/config/services.yml
services:
    app_cookie_voter:
        class: AppBundle\Security\CookieVoter
        tags:
            - { name: security.voter }

The name doesn’t matter but try to keep it relatively short. And the autocompleting I’m getting is from the nice Symfony2 plugin for PHPStorm. Our class doesn’t have any constructor arguments yet so I’m just leaving that key off.

The really important part is tags. You need to add one tag whose name is security.voter. This is like raising our hand for our voter and saying: “Hey Symfony, whenever somebody calls isGranted I want our voter to actually be called.”

So we have the voter, we have the service with the tag so let’s try it out! When we refresh... Bam! We see things dumped out: proof that our voter is being called.

Adding Multiple Actions (NOM, DONATE) to 1 Voter

I want to do one more crazy thing. Let’s pretend like we want to be able to donate our cookies to friends. Now I know that’s crazy why would you donate cookies to other people? But let’s just try it out. I don’t actually have the logic for this but that’s okay. Let’s go into index.html.twig and add a link for this. We’re just going to see if we can get the link to hide and show correctly:

{# app/Resources/views/Cookie/index.html.twig #}
{# ... #}

{% for cookie in cookies %}
    {# ... #}

    <td>
        {% if is_granted('DONATE', cookie) %}
            <a href="">Donate</a>
        {% endif %}
    </td>

    {# ... #}
{% endfor %}

Just like before I’m inventing this DONATE string. If we don’t do anything else and refresh, we’ll actually see that the link doesn’t show up. If no voters vote on our attribute, then by default it’s going to return false. Now why is our voter not voting on it? Because of the getSupportedAttributes function.

Let’s update that to return true for both NOM and DONUT...I mean DONATE:

// src/AppBundle/Security/CookieVoter.php
// ...

protected function getSupportedAttributes()
{
    return array('NOM', 'DONATE');
}

Now isGranted is going to be handling two different attributes, NOM and DONATE. This is the perfect situation for everyone’s beloved switch case statement. So let’s set that up, and we have two cases one for NOM and one for DONATE. And the logic for NOM is exactly what we had before so I’ll just copy that, paste that up and if it doesn’t get into either those if statements we’ll return false:

protected function isGranted($attribute, $object, $user = null)
{
    if (!is_object($user)) {
        return false;
    }

    $authChecker = $this->container->get('security.authorization_checker');

    switch ($attribute) {
        case 'NOM':
            if ($authChecker->isGranted('ROLE_COOKIE_MONSTER')) {
                return true;
            }

            if ($object->getBakerUsername() == $user->getUsername()) {
                return true;
            }

            return false;
        case 'DONATE':
            // todo ...
    }

    return false;
}

For the DONATE case, again, we can do literally anything we want to inside of this. If we want to go out and make crazy database queries to figure out something we can do that. In our case since chocolate cookies are the most delicious, let’s only give away cookies that aren’t chocolate. So, for my crazy business logic I’m just going to see if the word chocolate appears in the name of the cookie. If it does I’m not going to give it away. But if it doesn’t you can have it:

switch ($attribute) {
    case 'NOM':
        // ...
    case 'DONATE':
        return stripos($object->getFlavor(), 'chocolate') === false;
}

At the bottom of this function, I still have this false here. This should technically never get hit. Even if we pass something other than NOM or DONATE to isGranted Symfony is not going to call our voter because of the getSupportedAttributes.

So, you can put anything down here I like to throw an exception just incase something insane happens. But you’re going to be fine either way:

protected function isGranted($attribute, $object, $user = null)
{
    // ...

    switch ($attribute) {
        // ...
    }

    throw new \LogicException('How did we get here!?');
}

Cool, let’s see which cookies we can giveaway. This time we see the donate link only next to the cookies that aren’t chocolate. That’s perfect.

Let’s use some Constants

Now, some of you may be thinking that I’m crazy for having these strings like NOM and DONATE all over my application. And actually, I agree with you. Normally whenever I have a naked string somewhere I make it a constant instead. So in this case I’ll create two constants: ATTRIBUTE_NOM and ATTRIBUTE_DONATE:

// src/AppBundle/Security/CookieVoter.php
// ...

class CookieVoter extends AbstractVoter
{
    const ATTRIBUTE_NOM = 'NOM';
    const ATTRIBUTE_DONATE = 'DONATE';

    // ...

    protected function getSupportedAttributes()
    {
        return array(self::ATTRIBUTE_NOM, self::ATTRIBUTE_DONATE);
    }

    // ...

    protected function isGranted($attribute, $object, $user = null)
    {
        // ...

        switch ($attribute) {
            case self::ATTRIBUTE_NOM:
                // ...
            case self::ATTRIBUTE_DONATE:
                // ...
        }

        throw new \LogicException('How did we get here!?');
    }
}

Then we can use these inside of getSupportedAttributes and later we can use it inside of the isGranted function. This helps out with typos but it also allows us, if we want to, to put some PHP documentation above those constants so future us can come and read what nom and donate actually mean.

We can also go into our CookieController and use the constant there:

// src/AppBundle/Controller/CookieController.php
// ...

if (!$this->isGranted(CookieVoter::ATTRIBUTE_NOM, $cookie)) {
    throw $this->createAccessDeniedException(
        'You did not bake this delicious cookie!'
    );
}

And yes we can also use the constants inside of the twig template with twig’s constant() function, but honestly it’s kind of ugly so for me I just keep the strings here.

Go Security Voters Go!

So security voters are all about solving that case when you need figure out if a user has access to do something to a specific object. They help to keep your template logic and your controller logic really simple and they’re one of my favorite features. So try them out and let me know what you think.

Symfony also has an ACL system but it’s incredibly complex and I only recommend that you use it if you have really complex authorization requirements. If you can somehow write a few lines of code to figure out if a user has access to do something do that in a voter don’t worry about ACL.

Alright see you guys next time!

Leave a comment!

  • 2016-02-23 Daniel Noyola

    I love documentation http://symfony.com/doc/current...

  • 2016-02-23 weaverryan

    Yes, it's called Voter now! And we made it *even* a little bit easier to use. For people using Symfony 3, that's it's definitely important to know what the class is called now.

    Cheers!

  • 2016-02-23 Daniel Noyola

    it might be worth mentioning that the class AbstractVoter doesn't exits in Symfony 3 :(

  • 2016-01-13 tomhv

    Thanks, Ryan. That makes a lot of sense.

  • 2016-01-13 weaverryan

    Hmm, yea, I see what you're asking - I have had this urge before too :). So, *maybe*. The question is: will the user (by taking normal actions - e.g. clicking a link) be taken to a page where the cookie will not be ready to be eaten? If so, then if you include this logic in a voter, then if the user hits this page while navigating, they will see the 403 Access Denied page... which isn't a great user experience. So if it would be normal for a user to access this page and you want to display a nice message like "Hey, this cookie is still baking!", then I would not use voters. But if it is quite abnormal for the user to access this page (maybe they are getting clever and manually changing the URL to a cookie that is not ready yet), then yea - user a voter!

    Cheers!

  • 2016-01-12 tomhv

    Could/would you include non-security logic in a voter?


    if (!$cookie->isReadyToBeEaten()) {
    return false;
    }
  • 2015-12-16 Joe Holland

    Great! Thanks for your help and thanks for the lesson.

  • 2015-12-15 weaverryan

    Hey Joe!

    If you use the Download button on this page and "Download Course Code", you'll get the starting and ending version of the code. If you're familiar with Symfony, you can get either codebase working quickly by opening a terminal, moving into the directory (e.g. start), then running:

    composer install
    ./app/console doctrine:database:create
    ./app/console doctrine:schema:create
    ./app/console doctrine:fixtures:load

    But the last command loads up the database by using the fixture files you see here: https://github.com/knpuniversi...

    Let me know if that helps!

  • 2015-12-15 Joe Holland

    Do you provide a download for the script to create the sf2_voters database?

  • 2015-09-03 aguidis

    Thanks for your clarification. I get it :) This new service sounds great, I'm looking forward to use this one !

  • 2015-09-03 weaverryan

    Hey Adrien!

    Yes! But... also no :). Your solution works just fine, unless you're taking advantage of the "role hierarchy" in security.yml. But if you aren't, then you can keep it simple and do this. In Symfony 2.8, we made a change that allows injecting the "security.access.decision_manager" service directly, which has a decide() method you can call on it (https://github.com/symfony/sym... - similar to isGranted(), but you pass it the token and not the user. We still need one small change, however, to make this useable with the AbstractVoter (i.e. the problem is that you're not given the token).

    Long way to say again, YES! But no... but in 2.8, you should be able to do it the "right" way without injecting the container.

    Cheers!

  • 2015-09-03 aguidis

    Hi Ryan,

    Thanks for this tutorial. I just have a suggestion about the way to check the current user's role in the voter, I tested another way to do it :

    if (in_array('ROLE_ADMIN', $user->getRoles())) {
    return true;
    }

    So we don't need to inject the container right ?

  • 2015-06-24 weaverryan

    Hi there!

    Phew, good quest - this can be a confusing issue, because *a lot* of what you should do depends on your exact situation. I'll make some points :)

    1) The easiest way to assign roles to users if via a User.roles field. I don't think creating a Role entity makes sense - because it doesn't make sense for an admin to be able to create a new role (e.g. if they created ROLE_MANAGE_BLOG, that wouldn't be used in the code anywhere).

    2) Normally, there is in fact no single source for roles - you just kind of use them in your controller with isGranted() or in access_control. If you want a single source (not a bad idea), I like creating a class - e.g. SecurityRoles - that has nothing on it but some constants for each role: const ROLE_MANAGE_BLOG = 'ROLE_MANAGE_BLOG'. Then at least you can reference these within your code and you have one spot to look at for all the roles. You *are* right that eventually, you could have roles stored in a user that aren't used in your codebase. But I don't think that's a problem - it's just a little bit of extra data stored in the user. And if the user's roles were ever updated by the admin, it would correctly save *without* the old roles.

    3) Assuming you have a User.roles field, this would *only* store things like ROLE_USER, ROLE_MANAGE_BLOG, ROLE_*, etc. It would NOT store things like ALLOW_EDIT. Why? Because when you say isGranted('ALLOW_EDIT', $blogPost), this does not check to see if the user has the ALLOW_EDIT role. Instead, it calls your custom voter that handles ALLOW_EDIT and then you perform whatever logic you need. In technical terms, "roles" are only those things that start with ROLE_ (and are handled by the RoleVoter, which checks to see if the user has this role). Anything else you pass to isGranted is called an "attribute" - and your custom handler takes care of those.

    Does that help? :)

  • 2015-06-23 Theravadan

    If you wanted an admin to be able to dynamically grant user with a role how would you go about it?
    I meant there would be several problems:
    - creating a roles entity that would store all available roles in the database, but now there is no single source for roles - after some time you would end up with some roles that exist in the code base but do not exist in the database and the other way around.
    - should user roles and other strings that are to have the same functionality - ROLE_USER, ALLOW_EDIT be stored in the same column - roles of the user?
    Thanks for your time guys, you are awesome! :)

  • 2015-01-20 Victor Bocharsky

    It's very cool when you *focus* on new features in latest version like `Simpler Security Voters`, `security.authorization_checker`, `$this->isGranted()`, etc. in your screencasts. Thanks for this!

  • 2015-01-19 Sergey Smirnov

    Thank you very much for ths tutorial! This tutorial is incredibly simple and informative, I appreciate it!

  • 2014-11-06 Guest

    Thanks for taking the time to come out with this great tutorial. I've run into an issue where the var_dump(...);die; from the CookieVoter/isGranted function is not appearing, instead I get the 403 error in it's place.

    - Using the following in the CookieController/NomAction: if (!$this->get('security.context')->isGranted('NOM', $cookie)) as I'm not running Symfony 2.6 (running 2.5.2).

    - I've registered the services.yml as shown in the video in the config.yml but it doesn't appear to be calling the voter?

    Cheers!

  • 2014-11-04 weaverryan

    Woot! Do it up - yay voters!

  • 2014-11-04 Kegan VanSickle

    This is awesome. I am going to update my project to reflect these changes, much cleaner and efficient. Thanks Ryan!

  • 2014-10-26 Toni Van de Voorde

    Thanks Ryan. Great Post.

  • 2014-10-25 Dreepto

    Great one, much appreciated !

  • 2014-10-23 Sergio

    Thank you guys, nice video, again =)