Building and advanced filtering on Symfony2 menus with KnpMenuBundle, based on user roles
Recently, I was working on our company's website and it was required to have the header menu changed based on the users permissions. The menu is built using the KnpMenuBundle and is configured like this:
services:
app.backend.menu.builder:
class: App\BackendBundle\Menu\Builder
arguments: ["@service_container"]
app.backend.menu:
class: Knp\KnpMenu\MenuItem
factory_service: app.backend.menu.builder
factory_method: createAdminMenu
arguments: ["@knp_menu.factory"]
tags:
- { name: knp_menu.menu, alias: admin_menu }
The above code is placed in the services.yml
file of the same bundle that the menu builder is and it allows us to use the admin_menu
alias to render the form in our templates.
Our current menu template allows simple dropdown menus using the Twitter Bootstrap Theme so we ended up with a menu builder that looks like this:
namespace App\BackendBundle\Menu;
use Knp\Menu\FactoryInterface;
use Knp\Menu\ItemInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
class Builder
{
/** @var ContainerInterface */
private $container;
function __construct(ContainerInterface $container)
{
$this->container = $container;
}
public function createAdminMenu(FactoryInterface $factory)
{
$menu = $factory->createItem('root');
// dashboard
$menu->addChild('Dashboard', array(
'route' => 'backend_home'
))
;
// quick links
$menu->addChild('Quick links', array())->setAttribute('dropdown', true)
->addChild('New post', array(
'route' => 'backend_post_new'
))->getParent()
->addChild('New category', array(
'route' => 'backend_category_new'
))->getParent()
->addChild('New user', array(
'route' => 'backend_user_new'
))->getParent()
->addChild('New link', array(
'route' => 'backend_link_new'
))->getParent()
->addChild('New developer', array(
'route' => 'backend_developer_new'
))->getParent()
->addChild('New project', array(
'route' => 'backend_project_new'
))->getParent()
->addChild('New testimonial', array(
'route' => 'backend_testimonial_new'
))->getParent()
->addChild('New skill', array(
'route' => 'backend_skill_new'
))->getParent()
;
// blog
$menu->addChild('Blog', array())->setAttribute('dropdown', true)
->addChild('Posts', array(
'route' => 'backend_post'
))->getParent()
->addChild('Categories', array(
'route' => 'backend_category'
))->getParent()
;
$menu->addChild('Misc', array())->setAttribute('dropdown', true)
->addChild('Links', array('route' => 'backend_link'))->getParent()
->addChild('Developers', array('route' => 'backend_developer'))->getParent()
->addChild('Skills', array('route' => 'backend_skill'))->getParent()
->addChild('Project', array('route' => 'backend_project'))->getParent()
->addChild('Testimonials', array('route' => 'backend_testimonial'))->getParent()
->addChild('Users', array('route' => 'backend_user'))->getParent();
if ($this->container->get('session')->has('real_user_id')) {
$menu->addChild('Deimpersonate', array('route' => 'backend_user_deimpersonate'));
}
$menu->addChild('Log out', array('route' => 'fos_user_security_logout'))->getParent();
return $menu;
}
}
The above code builds a nice menu but it's simply not enough if you want your editors to just see the Blog section, for example. I could've manually filtered the user menu with if/else statements at this point, but that doesn't really feel like Symfony, does it?
To make our builder feel like it's a Symfony menu I have decided that I will filter the menu based on the @Secure
annotations, that I added to the controller methods and to do this, I needed access to 3 components:
- the router component - this allows me to retrieve Route objects so that I can get the class and method that is mapped for the route that I have passed to the
addChild()
method of the menu factory - a metadata reader - this allows me to read the metadata for the controller methods that I will be retrieving with the router component
- the security context - this allows me to check if the current user has a certain role that is configured with
@Route(roles="...")
With this in mind I've created 3 properties on the menu builder that would hold references to the components that I need in order to achieve my goal, so the constructor and properties section of my menu builder now looks like this:
/** @var ContainerInterface */
private $container;
/** @var Router */
private $router;
/**
* @var SecurityContext
*/
private $securityContext;
/**
* @var \JMS\SecurityExtraBundle\Metadata\Driver\AnnotationDriver
*/
private $metadataReader;
function __construct(ContainerInterface $container)
{
$this->container = $container;
$this->router = $this->container->get('router');
$this->securityContext = $this->container->get('security.context');
$this->metadataReader = new AnnotationDriver(new \Doctrine\Common\Annotations\AnnotationReader());
}
Now that this is set, I can go on and write the 2 methods that I need, filterMenu
and the hasRouteAccess
.
The filterMenu
method is quite simple, it just loops through the menu's child elements recursively and checks whether or not the current user hasRouteAccess
.
public function filterMenu(ItemInterface $menu)
{
foreach ($menu->getChildren() as $child) {
/** @var \Knp\Menu\MenuItem $child */
list($route) = $child->getExtra('routes');
if ($route && !$this->hasRouteAccess($route)) {
$menu->removeChild($child);
}
$this->filterMenu($child);
}
return $menu;
}
Now comes the slightly more complicated part of our current menu builder, the hasRouteAccess
method. This method will has to check if the current user is logged in (although this is most likely not needed as this part of the application is already behind a firewall that requires authentication) and then grab the route object based on the $routeName
parameter and then check if any of the roles required by @Route
annotation.
/**
* @param $class
* @return \JMS\SecurityExtraBundle\Metadata\ClassMetadata
*/
public function getMetadata($class)
{
return $this->metadataReader->loadMetadataForClass(new \ReflectionClass($class));
}
public function hasRouteAccess($routeName)
{
$token = $this->securityContext->getToken();
if ($token->isAuthenticated()) {
$route = $this->router->getRouteCollection()->get($routeName);
$controller = $route->getDefault('_controller');
list($class, $method) = explode('::', $controller, 2);
$metadata = $this->getMetadata($class);
if (!isset($metadata->methodMetadata[$method])) {
return false;
}
foreach ($metadata->methodMetadata[$method]->roles as $role) {
if ($this->securityContext->isGranted($role)) {
return true;
}
}
}
return false;
}
Putting everything together, we end up with something like this:
namespace App\BackendBundle\Menu;
use JMS\SecurityExtraBundle\Metadata\Driver\AnnotationDriver;
use Knp\Menu\FactoryInterface;
use Knp\Menu\ItemInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Security\Core\SecurityContext;
class Builder
{
/** @var ContainerInterface */
private $container;
/** @var Router */
private $router;
/**
* @var SecurityContext
*/
private $securityContext;
/**
* @var \JMS\SecurityExtraBundle\Metadata\Driver\AnnotationDriver
*/
private $metadataReader;
function __construct(ContainerInterface $container)
{
$this->container = $container;
$this->router = $this->container->get('router');
$this->securityContext = $this->container->get('security.context');
$this->metadataReader = new AnnotationDriver(new \Doctrine\Common\Annotations\AnnotationReader());
}
/**
* @param $class
* @return \JMS\SecurityExtraBundle\Metadata\ClassMetadata
*/
public function getMetadata($class)
{
return $this->metadataReader->loadMetadataForClass(new \ReflectionClass($class));
}
public function hasRouteAccess($routeName)
{
$token = $this->securityContext->getToken();
if ($token->isAuthenticated()) {
$route = $this->router->getRouteCollection()->get($routeName);
$controller = $route->getDefault('_controller');
list($class, $method) = explode('::', $controller, 2);
$metadata = $this->getMetadata($class);
if (!isset($metadata->methodMetadata[$method])) {
return false;
}
foreach ($metadata->methodMetadata[$method]->roles as $role) {
if ($this->securityContext->isGranted($role)) {
return true;
}
}
}
return false;
}
public function filterMenu(ItemInterface $menu)
{
foreach ($menu->getChildren() as $child) {
/** @var \Knp\Menu\MenuItem $child */
$routes = $child->getExtra('routes');
if ($routes !== null) {
$route = current(current($routes));
if ($route && !$this->hasRouteAccess($route)) {
$menu->removeChild($child);
}
}
$this->filterMenu($child);
}
return $menu;
}
public function createAdminMenu(FactoryInterface $factory)
{
$menu = $factory->createItem('root');
// dashboard
$menu->addChild('Dashboard', array(
'route' => 'backend_home'
))
;
// quick links
$menu->addChild('Quick links', array())->setAttribute('dropdown', true)
->addChild('New post', array(
'route' => 'backend_post_new'
))->getParent()
->addChild('New category', array(
'route' => 'backend_category_new'
))->getParent()
->addChild('New user', array(
'route' => 'backend_user_new'
))->getParent()
->addChild('New link', array(
'route' => 'backend_link_new'
))->getParent()
->addChild('New developer', array(
'route' => 'backend_developer_new'
))->getParent()
->addChild('New project', array(
'route' => 'backend_project_new'
))->getParent()
->addChild('New testimonial', array(
'route' => 'backend_testimonial_new'
))->getParent()
->addChild('New skill', array(
'route' => 'backend_skill_new'
))->getParent()
;
// blog
$menu->addChild('Blog', array())->setAttribute('dropdown', true)
->addChild('Posts', array(
'route' => 'backend_post'
))->getParent()
->addChild('Categories', array(
'route' => 'backend_category'
))->getParent()
;
$menu->addChild('Misc', array())->setAttribute('dropdown', true)
->addChild('Links', array('route' => 'backend_link'))->getParent()
->addChild('Developers', array('route' => 'backend_developer'))->getParent()
->addChild('Skills', array('route' => 'backend_skill'))->getParent()
->addChild('Project', array('route' => 'backend_project'))->getParent()
->addChild('Testimonials', array('route' => 'backend_testimonial'))->getParent()
->addChild('Users', array('route' => 'backend_user'))->getParent();
$this->filterMenu($menu);
if ($this->container->get('session')->has('real_user_id')) {
$menu->addChild('Deimpersonate', array('route' => 'backend_user_deimpersonate'));
}
$menu->addChild('Log out', array('route' => 'fos_user_security_logout'))->getParent();
return $menu;
}
}
Our menu templates looks something like this:
{% extends 'knp_menu.html.twig' %}
{% macro attributes(attributes) %}
{% for name, value in attributes %}
{%- if value is not none and value is not sameas(false) -%}
{{- ' %s="%s"'|format(name, value is sameas(true) ? name|e : value|e)|raw -}}
{%- endif -%}
{%- endfor -%}
{% endmacro %}
{% block root %}
{% set listAttributes = item.childrenAttributes %}
{{ block('list') -}}
{% endblock %}
{% block children %}
{# save current variables #}
{% set currentOptions = options %}
{% set currentItem = item %}
{# update the depth for children #}
{% if options.depth is not none %}
{% set options = currentOptions|merge({'depth': currentOptions.depth - 1}) %}
{% endif %}
{% for item in currentItem.children %}
{{ block('item') }}
{% endfor %}
{# restore current variables #}
{% set item = currentItem %}
{% set options = currentOptions %}
{% endblock %}
{% block spanElement %}
{% if not item.attribute('dropdown') %}
<span{{ _self.attributes(item.labelAttributes) }}>{{ block('label') }}</span>
{% endif %}
{% endblock %}
{% block list %}
{% if item.hasChildren and options.depth is not sameas(0) and item.displayChildren %}
{% if item.attribute('dropdown') %}
<ul class="nav x">
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">{{ item.label }} <b class="caret"></b></a>
<ul class="dropdown-menu">
{{ block('children') }}
</ul>
</li>
</ul>
{% else %}
<ul class="nav">
{{ block('children') }}
</ul>
{% endif %}
{% endif %}
{% endblock %}
And our methods are secured with something like this:
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use JMS\SecurityExtraBundle\Annotation\Secure;
class SomeController extends Controller
{
/**
* Lists all BlogCategory entities.
*
* @Route("/", name="routeName")
* @Template()
* @Secure(roles="ROLE_EDITOR")
*/
public function indexAction() {
return array();
}
}
Once you put everything together, you will have a nice menu that will show the user only what he actually has access to and it will eliminate the need to write all those ugly if/else statements.
The 2 main things to notice here, is that this class requires you to have JMSSerializerBundle
installed and you need to use the @Secure
annotation on your routes.
Enjoy,
Later edit:
Between the first version of this article and now (22nd February, 2014), the annotation reader has changed so the $metadata->methodMetadata[$method]
will always be empty if we're loading everything through the security.extra.metadata_factory
service. We have changed the code in this article accordingly to ensure this still works.
You can find an example app here https://github.com/tsslabs/symfony2-advanced-menus
Let us know what you think.