Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Role Hierarchy

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

Right now, our site has two types of users: normal users and admin users. If you're a normal user, you can vote on answers and probably do a bunch of other things once we're done. If you're an admin, you can also go to the admin section.

There's not much here yet... but in theory, an admin user might have access to edit questions, answers or manage user data. And... a lot of sites are just this simple: you're either a normal user or an admin user.

Organizing Role Names

But in a larger company, things might not be so simple: you might have many types of admin users. Some will have access to some sections and other access to other sections. The question is: what's the best way to organize our roles to accomplish this?

Well, there are really only two possibilities. The first is to assign roles to users that are named after the type of user. For example, you assign roles to users like ROLE_HUMAN_RESOURCES or ROLE_IT or ROLE_PERSON_WHO_OWNS_THE_COMPANY. Then, you deny access to controllers using these strings. But... I don't love this. You end up in weird situations where, in a controller, you realize that you need to allow access to ROLE_HUMAN_RESOURCES or ROLE_IT, which is just messy.

Ok, so what's the second option? To protect controllers with role names that describe what access that role gives you. For example, at the bottom of this controller, let's create a pretend admin page for moderating answers. Set the URL to /admin/answers... and call it adminAnswers():

... lines 1 - 10
class AdminController extends AbstractController
{
... lines 13 - 65
/**
* @Route("/admin/comments")
*/
public function adminComments()
{
... lines 71 - 72
return new Response('Pretend comments admin page');
}
}

Imagine that our "human resources" department and IT department should both have access to this. Well, as I mentioned earlier, I do not want to try to put logic here that allows ROLE_HUMAN_RESOURCES or ROLE_IT.

Instead, say $this->denyAccessUnlessGranted() and pass this ROLE_COMMENT_ADMIN, a role name that I just invented that describes what is being protected:

... lines 1 - 10
class AdminController extends AbstractController
{
... lines 13 - 65
/**
* @Route("/admin/comments")
*/
public function adminComments()
{
$this->denyAccessUnlessGranted('ROLE_COMMENT_ADMIN');
return new Response('Pretend comments admin page');
}
}

Oh, dummy Ryan! I should've called this ROLE_ANSWER_ADMIN - I keep using "comment" when I mean "answer". This will work fine - but ROLE_ANSWER_ADMIN is really the best name.

Anyways, what I love about this is how clear the controller is: you can't access this unless you have a role that's specific to this controller. There's just one problem: if we go to /admin/answers, we get access denied... because we do not have this role.

You can probably see the problem with this approach. Each time we create a new section and protect it with a new role name, we're going to need to add that role to every user in the database that should have access. That sounds like a pain in the butt!

Hello role_hierarchy

Fortunately, Symfony has a feature just for this called role hierarchy. Open up config/packages/security.yaml and, anywhere inside of here... but I'll put it near the top, add role_hierarchy. Below this, say ROLE_ADMIN and set this to an array. For now, just include ROLE_COMMENT_ADMIN:

security:
... lines 2 - 6
role_hierarchy:
ROLE_ADMIN: ['ROLE_COMMENT_ADMIN']
... lines 9 - 58

This looks just as simple as it is. It says:

If you have ROLE_ADMIN, then you automatically also have ROLE_COMMENT_ADMIN.

The result? If we refresh the page, access granted!

The idea is that, for each "type" of user - like "human resources", or IT - you would create a new item in role_hierarchy for them, like ROLE_HUMAN_RESOURCES set to an array of whatever roles it should have.

For example, let's pretend that we are also protecting another admin controller with ROLE_USER_ADMIN:

security:
... lines 2 - 6
role_hierarchy:
... line 8
ROLE_HUMAN_RESOURCES: ['ROLE_USER_ADMIN']
... lines 10 - 59

In this case, if you have ROLE_HUMAN_RESOURCES, then you automatically get ROLE_USER_ADMIN... which gives you access to modify user data. And if you have ROLE_ADMIN, maybe you can also access this section:

security:
... lines 2 - 6
role_hierarchy:
ROLE_ADMIN: ['ROLE_COMMENT_ADMIN', 'ROLE_USER_ADMIN']
ROLE_HUMAN_RESOURCES: ['ROLE_USER_ADMIN']
... lines 10 - 59

With this setup, each time we add a new section to our site and protect it with a new role, we only need to go to role_hierarchy and add it to whatever groups need it. We don't need to change the roles in the database for anyone. And in the database, most - or all - users will only need one role: the one that represents the "type" of user they are, like ROLE_HUMAN_RESOURCES.

Speaking of admin users, when we're debugging a customer issue on our site, sometimes it would be really useful if we could temporarily log into that user's account... just to see what they're seeing. In Symfony, that's totally possible. Let's talk about impersonation next.

Leave a comment!

2
Login or Register to join the conversation

Maybe off topic question - how to create hierarchy on Database level too.
I have worker 1 - have similar access that worker 2, but information what worker 1 see about summary report from company 1 and worker 2 summary from company 2. Twig for summary identical. I think i need to create method on DataRepository with argument ? It is correct way?

Reply

Hey Mepcuk!

Hmm. I'm not sure if this is really a "role hierarchy" that you need in the database, or just a smart voter system + smart queries.

The most important thing to focus on first is how you want to structure the data and relations in the database. It sounds to me like you have this setup:

A) worker 1 and worker 2 generally have similar access (i.e. they probably have similar or identical roles)
B) BUT, worker 1 can see "company 1" summary report only and worker 2 can see "company 2" summary report only.

If this is the case, then you need to protect the "summary" report with a voter - e.g. $this->denyAccessUnlessGranted('SUMMARY_REPORT', $company).

In the database, you will naturally have some way that "worker 1" and "company 1" are related. This part has nothing do with security, it's just part of your data model. For example, maybe your Worker entity has a ManyToOne to Company, which is how you know that worker 1 works for company 1.

Assuming you have the database all modeled how you want, then in your custom voter, you just use that relationship. For example, you would (in your voter) get the current user object (pretend it is "user 1") and then look at the $company object that was passed as the subject (pretend it is "company 1"). Then, if $user->getCompany() === $company, you know that access is granted. Else, access is denied.

Let me know if this helps!

Cheers!

Reply
Cat in space

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

This tutorial also works great for Symfony 6!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.3", // v3.3.0
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^2.1", // 2.6.3
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
        "doctrine/orm": "^2.7", // 2.10.1
        "knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
        "knplabs/knp-time-bundle": "^1.11", // v1.16.1
        "pagerfanta/doctrine-orm-adapter": "^3.3", // v3.3.0
        "pagerfanta/twig": "^3.3", // v3.3.0
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "scheb/2fa-bundle": "^5.12", // v5.12.1
        "scheb/2fa-qr-code": "^5.12", // v5.12.1
        "scheb/2fa-totp": "^5.12", // v5.12.1
        "sensio/framework-extra-bundle": "^6.0", // v6.2.0
        "stof/doctrine-extensions-bundle": "^1.4", // v1.6.0
        "symfony/asset": "5.3.*", // v5.3.4
        "symfony/console": "5.3.*", // v5.3.7
        "symfony/dotenv": "5.3.*", // v5.3.8
        "symfony/flex": "^1.3.1", // v1.17.5
        "symfony/form": "5.3.*", // v5.3.8
        "symfony/framework-bundle": "5.3.*", // v5.3.8
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/property-access": "5.3.*", // v5.3.8
        "symfony/property-info": "5.3.*", // v5.3.8
        "symfony/rate-limiter": "5.3.*", // v5.3.4
        "symfony/runtime": "5.3.*", // v5.3.4
        "symfony/security-bundle": "5.3.*", // v5.3.8
        "symfony/serializer": "5.3.*", // v5.3.8
        "symfony/stopwatch": "5.3.*", // v5.3.4
        "symfony/twig-bundle": "5.3.*", // v5.3.4
        "symfony/ux-chartjs": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.8
        "symfony/webpack-encore-bundle": "^1.7", // v1.12.0
        "symfony/yaml": "5.3.*", // v5.3.6
        "symfonycasts/verify-email-bundle": "^1.5", // v1.5.0
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.3
        "twig/string-extra": "^3.3", // v3.3.3
        "twig/twig": "^2.12|^3.0" // v3.3.3
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
        "symfony/debug-bundle": "5.3.*", // v5.3.4
        "symfony/maker-bundle": "^1.15", // v1.34.0
        "symfony/var-dumper": "5.3.*", // v5.3.8
        "symfony/web-profiler-bundle": "5.3.*", // v5.3.8
        "zenstruck/foundry": "^1.1" // v1.13.3
    }
}
userVoice