Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Security Voter

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Our security is turning into a madhouse, which I don't like. I want my security logic to be simple and centralized. The way to do that in Symfony is with a voter. Let's go create one.

At the command line, run:

php ./bin/console make:voter

Call it DragonTreasureVoter. It's pretty common to have one voter per entity that you need security logic for. So this voter will make all decisions related to DragonTreasure: can the current user edit one, delete one, view one: whatever we eventually need.

Go open it up: src/Security/Voter/DragonTreasureVoter.php:

... lines 1 - 2
namespace App\Security\Voter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
class DragonTreasureVoter extends Voter
{
public const EDIT = 'POST_EDIT';
public const VIEW = 'POST_VIEW';
protected function supports(string $attribute, mixed $subject): bool
{
// replace with your own logic
// https://symfony.com/doc/current/security/voters.html
return in_array($attribute, [self::EDIT, self::VIEW])
&& $subject instanceof \App\Entity\DragonTreasure;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
// if the user is anonymous, do not grant access
if (!$user instanceof UserInterface) {
return false;
}
// ... (check conditions and return true to grant permission) ...
switch ($attribute) {
case self::EDIT:
// logic to determine if the user can EDIT
// return true or false
break;
case self::VIEW:
// logic to determine if the user can VIEW
// return true or false
break;
}
return false;
}
}

Before we talk about this class, let me show you how we'll use it. In DragonTreasure, we're still going to use the is_granted() function. But for the first argument, pass EDIT... which is just a string I'm making up: you'll see how that's used in the voter. Then pass object:

... lines 1 - 27
#[ApiResource(
... lines 29 - 30
operations: [
... lines 32 - 40
new Patch(
security: 'is_granted("EDIT", object)',
... line 43
),
... lines 45 - 47
],
... lines 49 - 65
)]
... lines 67 - 87
class DragonTreasure
{
... lines 90 - 247
}

We normally pass is_granted() a single argument: a role! But you can also pass it any random string like EDIT... as long as you have a voter set up to handle that. If your voter needs some extra info to make its decision, you can pass that as the second argument.

On a high level, we're asking the security system whether or not the current user is allowed to EDIT this DragonTreasure object. DragonTreasureVoter will make that decision.

Copy this and paste it down for securityPostDenormalize:

... lines 1 - 27
#[ApiResource(
... lines 29 - 30
operations: [
... lines 32 - 40
new Patch(
security: 'is_granted("EDIT", object)',
securityPostDenormalize: 'is_granted("EDIT", object)',
),
... lines 45 - 47
],
... lines 49 - 65
)]
... lines 67 - 87
class DragonTreasure
{
... lines 90 - 247
}

How Voters Works

So here's the deal: anytime that is_granted() is called - from anywhere, not just from API Platform - Symfony loops through a list of "voter" classes and tries to figure out which one knows how to make that decision. When we check for a role, there's an existing voter that knows how to handle that. In the case of EDIT, there is no core voter that knows how to handle that. So we'll make DragonTreasureVoter able to handle it.

To determine who can handle an isGranted call, Symfony calls supports() on each voter passing the same two arguments. For our case, $attribute will be EDIT and $subject will be the DragonTreasure object:

... lines 1 - 8
class DragonTreasureVoter extends Voter
{
... lines 11 - 13
protected function supports(string $attribute, mixed $subject): bool
{
... lines 16 - 19
}
... lines 21 - 43
}

MakeBundle generated a voter that handles checking if we can "edit" or "view" a DragonTreasure. We don't need that "view" right now, so I'll delete it. Below, change this to an instance of DragonTreasure and I'll retype the end and hit tab to add the use statement... just to clean things up:

... lines 1 - 9
class DragonTreasureVoter extends Voter
{
public const EDIT = 'EDIT';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::EDIT])
&& $subject instanceof DragonTreasure;
}
... lines 19 - 38
}

So if someone calls isGranted() and passes the string EDIT and a DragonTreasure object, we know how to make that decision.

Oh, and I need to change the constant value to EDIT to match the EDIT string we're passing to is_granted().

If we return true from supports(), Symfony will then call voteOnAttribute(). Very simply: we return true if the user should have access, false otherwise.

To start, just return false:

... lines 1 - 9
class DragonTreasureVoter extends Voter
{
... lines 12 - 19
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
return false;
... lines 23 - 37
}
}

If we've played our cards right, our voter will swoop in like an overactive superhero every time we make a PATCH request and slam the access door shut. Before we try test that theory, remove the "view" case down here:

... lines 1 - 9
class DragonTreasureVoter extends Voter
{
... lines 12 - 19
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
return false;
$user = $token->getUser();
// if the user is anonymous, do not grant access
if (!$user instanceof UserInterface) {
return false;
}
// ... (check conditions and return true to grant permission) ...
switch ($attribute) {
case self::EDIT:
// logic to determine if the user can EDIT
// return true or false
break;
}
return false;
}
}

Ok, let's make sure our tests fail! Run:

symfony php bin/phpunit

And... yes! Two tests fail: both because access is denied. Our voter is being called.

Adding the Voter Logic

Back in the class, voteOnAttribute() is passed the attribute - EDIT - the $subject - a DragonTreasure object and a $token, which is a wrapper around the current User object. So we're first checking to make sure that the user is actually authenticated.

After that, assert() that $subject is an instance of DragonTreasure because this method should only ever be called when supports() return true:

... lines 1 - 9
class DragonTreasureVoter extends Voter
{
... lines 12 - 19
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
// if the user is anonymous, do not grant access
if (!$user instanceof UserInterface) {
return false;
}
assert($subject instanceof DragonTreasure);
// ... (check conditions and return true to grant permission) ...
... lines 31 - 40
}
}

I'm mostly writing this to help my editor know that $subject is a DragonTreasure: assert() is a handy way to do that.

The switch statement only has one case right now. And this is where our logic will live. Very simply: if $subject - that's the DragonTreasure - ->getOwner() equals $user, then return true. Otherwise, it will hit the break and return false:

... lines 1 - 9
class DragonTreasureVoter extends Voter
{
... lines 12 - 19
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
... lines 22 - 29
// ... (check conditions and return true to grant permission) ...
switch ($attribute) {
case self::EDIT:
if ($subject->getOwner() === $user) {
return true;
}
break;
}
return false;
}
}

This isn't all the logic we need, but it's a good start!

Try the tests now:

symfony php bin/phpunit

Down to one failure!

Checking for Roles in the Voter

What's next? Well, we don't have a test for it, but if we authenticate with an API token, in order to edit a treasure, you need to ROLE_TREASURE_EDIT, which you can get via the token scope.

So, in the voter, we need to check if the user has that role. Add a __construct() method and autowire Security - the one from SecurityBundle - $security:

... lines 1 - 5
use Symfony\Bundle\SecurityBundle\Security;
... lines 7 - 10
class DragonTreasureVoter extends Voter
{
... lines 13 - 14
public function __construct(private Security $security)
{
}
... lines 18 - 50
}

Then, below, before we check the owner, if not $this->security->isGranted('ROLE_TREASURE_EDIT'), then definitely return false:

... lines 1 - 10
class DragonTreasureVoter extends Voter
{
... lines 13 - 24
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
... lines 27 - 35
switch ($attribute) {
case self::EDIT:
if (!$this->security->isGranted('ROLE_TREASURE_EDIT')) {
return false;
}
if ($subject->getOwner() === $user) {
return true;
}
break;
}
... lines 48 - 49
}
}

The last test that's failing is testing that an admin can patch to edit any treasure. Because we've already injected the Security service, this is easy.

Let's pretend admin users will be able to do anything. So above the switch, if $this->security->isGranted('ROLE_ADMIN'), then return true:

... lines 1 - 10
class DragonTreasureVoter extends Voter
{
... lines 13 - 24
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
... lines 27 - 32
if ($this->security->isGranted('ROLE_ADMIN')) {
return true;
}
assert($subject instanceof DragonTreasure);
... lines 38 - 53
}
}

Moment of truth:

symfony php bin/phpunit

Voilà! Our logic has found a cozy home inside the voter, the security expression is now so simple it's almost scary, and we got to write our logic in PHP.

Next: let's explore hiding certain fields in the response based on the user.

Leave a comment!

2
Login or Register to join the conversation
Dejan94 Avatar

Voter dont work on Get Collection.
403
Error: Forbidden
For single resources it works.

Reply

Hey @Dejan94!

You're absolutely correct! And that's a really great point to bring up. Suppose you add a security attribute like this to your GetCollection:

new GetCollection(
    security: 'is_granted("EDIT", object)',
),

In this situation, the object is actually a "collection" - it's an array or maybe a Doctrine Collection object, I can't actually remember. The point is: it's not a single object. And that makes sene: if your endpoint returns 10 treasures, you might have access to EDIT 5 of them, but not the other 5.

So, unfortunately, for the GetCollection operation, if you want to use security to "filter" the collection automatically so that only some are shown, that's not done directly via security. Instead, in chapter 35 (will be released this week - but you can peek at it here https://symfonycasts.com/screencast/api-platform-security/query-extension or download the course code and look at the final directory to see what we did), we create a "query extension" that uses our security logic to modify the query itself to only returns the ones we have access to.

Let me know if that helps... or if it didn't ;).

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^3.0", // v3.1.2
        "doctrine/annotations": "^2.0", // 2.0.1
        "doctrine/doctrine-bundle": "^2.8", // 2.8.3
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.14", // 2.14.1
        "nelmio/cors-bundle": "^2.2", // 2.2.0
        "nesbot/carbon": "^2.64", // 2.66.0
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.16.1
        "symfony/asset": "6.2.*", // v6.2.5
        "symfony/console": "6.2.*", // v6.2.5
        "symfony/dotenv": "6.2.*", // v6.2.5
        "symfony/expression-language": "6.2.*", // v6.2.5
        "symfony/flex": "^2", // v2.2.4
        "symfony/framework-bundle": "6.2.*", // v6.2.5
        "symfony/property-access": "6.2.*", // v6.2.5
        "symfony/property-info": "6.2.*", // v6.2.5
        "symfony/runtime": "6.2.*", // v6.2.5
        "symfony/security-bundle": "6.2.*", // v6.2.6
        "symfony/serializer": "6.2.*", // v6.2.5
        "symfony/twig-bundle": "6.2.*", // v6.2.5
        "symfony/ux-react": "^2.6", // v2.7.1
        "symfony/ux-vue": "^2.7", // v2.7.1
        "symfony/validator": "6.2.*", // v6.2.5
        "symfony/webpack-encore-bundle": "^1.16", // v1.16.1
        "symfony/yaml": "6.2.*" // v6.2.5
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "mtdowling/jmespath.php": "^2.6", // 2.6.1
        "phpunit/phpunit": "^9.5", // 9.6.3
        "symfony/browser-kit": "6.2.*", // v6.2.5
        "symfony/css-selector": "6.2.*", // v6.2.5
        "symfony/debug-bundle": "6.2.*", // v6.2.5
        "symfony/maker-bundle": "^1.48", // v1.48.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/phpunit-bridge": "^6.2", // v6.2.5
        "symfony/stopwatch": "6.2.*", // v6.2.5
        "symfony/web-profiler-bundle": "6.2.*", // v6.2.5
        "zenstruck/browser": "^1.2", // v1.2.0
        "zenstruck/foundry": "^1.26" // v1.28.0
    }
}
userVoice