Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Security Voter & Entity Permissions

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

Thanks to ->setEntityPermission(), EasyAdmin now runs every entity in this list through the security system, passing ADMIN_USER_EDIT for each. If we were running this security check manually in a normal Symfony app, it would be the equivalent of $this->isGranted('ADMIN_USER_EDIT'), where you pass the actual entity object - the $user object - as the second argument.

Right now, when we do that, security always returns false because... I just invented this ADMIN_USER_EDIT string. To run our custom security logic, we need a voter.

Creating The Voter

Find your terminal and run:

symfony console make:voter

I'll call it "AdminUserVoter". Perfect! Spin over and open this: src/Security/Voter/AdminUserVoter.php. I'm not going to talk too deeply about how voters work: we talk about those in our Symfony Security tutorial. But basically, the supports() method will be called every time the security system is called. The first argument will be something like ROLE_ADMIN or, in our case, ADMIN_USER_EDIT. And also, in our case, $subject will be the User object. Our job is to return true in that situation.

... lines 1 - 4
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
class AdminUserVoter extends Voter
{
protected function supports(string $attribute, $subject): bool
{
// replace with your own logic
// https://symfony.com/doc/current/security/voters.html
return in_array($attribute, ['POST_EDIT', 'POST_VIEW'])
&& $subject instanceof \App\Entity\AdminUser;
}
... line 18
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
... lines 21 - 39
}
}

So let's check to see if the attribute is in an array with just ADMIN_USER_EDIT. I don't really need in_array() anymore, but I'll keep it in case I add more attributes later. Also check to make sure that $subject is an instanceof User.

... lines 1 - 7
use Symfony\Component\Security\Core\User\UserInterface;
... line 9
class AdminUserVoter extends Voter
{
protected function supports(string $attribute, $subject): bool
{
// replace with your own logic
// https://symfony.com/doc/current/security/voters.html
return in_array($attribute, ['ADMIN_USER_EDIT'])
&& $subject instanceof User;
}
... lines 19 - 38
}

That's it! Now, when the security system calls supports(), if we return true, then Symfony will call voteOnAttribute(). Our job there is simply to return true or false based on whether or not the current user should have access to this User object in the admin.

Once again, we're passed the $attribute, which will be ADMIN_USER_EDIT, and $subject, which will be the User object. To help my editor, add an extra "if" statement: if (!$subject instanceof User), then throw a new LogicException('Subject is not an instance of User?').

... lines 1 - 19
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
... lines 22 - 26
if (!$subject instanceof User) {
throw new \LogicException('Subject is not an instance of User?');
}
... lines 30 - 37
}
... lines 39 - 40

This should never happen, but that'll help my editor or static analysis. Finally, down in the switch (we only have one case right now), if that attribute is equal to ADMIN_USER_EDIT, then we want to allow access if $user === $subject. So if the currently-authenticated User object - that's what this is here - is equal to the User object that we're asking about for security, then grant access. Otherwise, deny access.

... lines 1 - 19
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
... lines 22 - 30
// ... (check conditions and return true to grant permission) ...
switch ($attribute) {
case 'ADMIN_USER_EDIT':
return $user === $subject;
}
return false;
}
... lines 39 - 40

Symfony will instantly know to use our voter thanks to auto configuration. So when we refresh... got it! We just see our one user and the message:

Some results can't be displayed because you don't have enough permissions.

Awesome! If you go down to the web debug toolbar, click the security icon, and then click "Access Decision", this shows you all the security decisions that were made during that request. It looks like ADMIN_USER_EDIT was called multiple times for the multiple rows on the page. With this user object - access was denied... and with this other user object - that's us - access was granted.

Entity permissions are also enforced when you go to the detail, edit, or delete pages. Again, if you go down to the web debug toolbar and click "Access Decision", at the bottom... you can see it checked for ADMIN_USER_EDIT.

Granting Access to ROLE_SUPER_ADMIN

This is great! Except that super admins should be able to see all users. Right now, no matter who I log in as, we're only going to show my user. To solve this, down in our logic, we can check to see if the user has ROLE_SUPER_ADMIN. But to do that, we need a service.

Add public function __construct(), and inject the Security service from Symfony (I'll call it $security). Hit "alt" + "enter", and go to "Initialize properties" to create that property and set it. Then, down here, return true if $user === $subject or if $this->security->isGranted('ROLE_SUPER_ADMIN').

... lines 1 - 7
use Symfony\Component\Security\Core\Security;
... lines 9 - 10
class AdminUserVoter extends Voter
{
private Security $security;
public function __construct(Security $security)
{
$this->security = $security;
}
... lines 19 - 27
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
... lines 30 - 39
switch ($attribute) {
case 'ADMIN_USER_EDIT':
return $user === $subject || $this->security->isGranted('ROLE_SUPER_ADMIN');;
}
... lines 44 - 45
}
}

Cool! I won't bother logging in as a super admin to try this. But if we did, we would now see every user.

Adding Permissions Logic to the Query

So there's just one tiny problem with our setup. Imagine that we have a lot of users - like thousands - which is pretty realistic. And our user is ID 500. In that case, you would actually see many pages of results here. And our user might be on page 200. So you'd see no results on page one... or two... or three... until finally, on page 200, you'd find our one result. So it can get a little weird if you have many items in an admin section, and many of them are hidden.

To fix this, we can modify the query that's made for the index page to only return the users we want. This is totally optional, but can make for a better user experience.

So far, we've been letting EasyAdmin query for every user or every question. But we do have control over that query. Open up UserCrudController and, anywhere, I'll go near the top, override a method from the base controller called createIndexQueryBuilder().

... lines 1 - 5
use Doctrine\ORM\QueryBuilder;
... lines 7 - 22
class UserCrudController extends AbstractCrudController
{
... lines 25 - 35
public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder
{
return parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters);
}
... lines 40 - 73
}

Here's how this works: the parent method starts the query builder for us. And it already takes into account things like the Search on top or "filters", which we'll talk about in a few minutes.

Instead of returning this query builder, set it to $queryBuilder. Then, because super admins should be able see everything, if $this->isGranted('ROLE_SUPER_ADMIN'), then just return the unmodified $queryBuilder so that all results are shown.

... lines 1 - 35
public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder
{
$queryBuilder = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters);
if ($this->isGranted('ROLE_SUPER_ADMIN')) {
return $queryBuilder;
}
... lines 43 - 48
}
... lines 50 - 85

But if we don't have ROLE_SUPER_ADMIN, that's where we want to change things. Add $queryBuilder->andWhere(). Inside the query, the alias for the entity will always be called "entity". So we can say entity.id = :id and ->setParameter('id', $this->getUser()->getId()). I don't get the auto complete on this because it thinks my user is just a UserInterface, but we know this will be our User entity which does have a getId() method. At the bottom, return $queryBuilder. And... I guess I could have just returned that right here... so let's do that.

... lines 1 - 35
public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder
{
... lines 38 - 43
$queryBuilder
->andWhere('entity.id = :id')
->setParameter('id', $this->getUser()->getId());
return $queryBuilder;
}
... lines 50 - 85

I love it! Let's try it! Spin over and... nice! Just our one result. And you don't see that message about results being hidden due to security... because, technically, none of them were hidden due to security. They were hidden due to our query. But regardless, permissions are still being enforced. If a user somehow got the edit URL to a User that they're not supposed to be able to access, the entity permissions will still deny that.

Next, each CRUD section has a nice search box on top. Yay! But EasyAdmin also has a great filter system where you can add more ways to slice and dice the data in each section. Let's explore those.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.0.2",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "doctrine/doctrine-bundle": "^2.1", // 2.5.5
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.2.1
        "doctrine/orm": "^2.7", // 2.10.4
        "easycorp/easyadmin-bundle": "^4.0", // v4.0.2
        "handcraftedinthealps/goodby-csv": "^1.4", // 1.4.0
        "knplabs/knp-markdown-bundle": "dev-symfony6", // dev-symfony6
        "knplabs/knp-time-bundle": "^1.11", // 1.17.0
        "sensio/framework-extra-bundle": "^6.0", // v6.2.5
        "stof/doctrine-extensions-bundle": "^1.4", // v1.7.0
        "symfony/asset": "6.0.*", // v6.0.1
        "symfony/console": "6.0.*", // v6.0.2
        "symfony/dotenv": "6.0.*", // v6.0.2
        "symfony/flex": "^2.0.0", // v2.0.1
        "symfony/framework-bundle": "6.0.*", // v6.0.2
        "symfony/mime": "6.0.*", // v6.0.2
        "symfony/monolog-bundle": "^3.0", // v3.7.1
        "symfony/runtime": "6.0.*", // v6.0.0
        "symfony/security-bundle": "6.0.*", // v6.0.2
        "symfony/stopwatch": "6.0.*", // v6.0.0
        "symfony/twig-bundle": "6.0.*", // v6.0.1
        "symfony/ux-chartjs": "^2.0", // v2.0.1
        "symfony/webpack-encore-bundle": "^1.7", // v1.13.2
        "symfony/yaml": "6.0.*", // v6.0.2
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.7
        "twig/twig": "^2.12|^3.0" // v3.3.7
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.1
        "symfony/debug-bundle": "6.0.*", // v6.0.2
        "symfony/maker-bundle": "^1.15", // v1.36.4
        "symfony/var-dumper": "6.0.*", // v6.0.2
        "symfony/web-profiler-bundle": "6.0.*", // v6.0.2
        "zenstruck/foundry": "^1.1" // v1.16.0
    }
}