If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeOur 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 | |
( | |
... 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 | |
( | |
... 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 | |
} |
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.
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!
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.
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!
// 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
}
}
Voter dont work on Get Collection.
403
Error: Forbidden
For single resources it works.