Skip to content

Commit

Permalink
Allow to define security permissions for menu items
Browse files Browse the repository at this point in the history
  • Loading branch information
javiereguiluz committed Jun 29, 2019
1 parent 9cb2b44 commit 56f4a82
Show file tree
Hide file tree
Showing 10 changed files with 228 additions and 11 deletions.
36 changes: 32 additions & 4 deletions doc/book/menu-configuration.rst
Expand Up @@ -162,6 +162,33 @@ and use any of the `valid link types`_:
menu item links to an external URL and doesn't define its ``rel`` option,
the ``rel="noreferrer"`` attribute is added automatically.

Permissions
~~~~~~~~~~~

By default, all backend users can see all menu items. If some of them should be
restricted for certain kind of users, use the ``permission`` option. The value
of this option is either a string or array which defines the
`Symfony security roles`_ that the user must have to see those menu items:

.. code-block:: yaml
# config/packages/easy_admin.yaml
easy_admin:
design:
menu:
# no permission defined, so all users can see this menu item
- { entity: 'Product' }
# only users with the ROLE_SUPER_ADMIN role will see this menu item
- { entity: 'User', permission: 'ROLE_SUPER_ADMIN' }
# when defining multiple roles, the user must have at least one of them
# or all of them, depending on the configuration of your Symfony application
# by default: user must have at least one of the roles
# see https://symfony.com/doc/current/security/access_control.html#access-enforcement
- { entity: 'Category', permission: ['ROLE_BETA', 'ROLE_ADMIN'] }
# ...
Changing the Backend Index Page
-------------------------------

Expand Down Expand Up @@ -314,16 +341,17 @@ advanced menus with two-level submenus and all kind of items:
icon: 'users'
children:
- { label: 'New Invoice', icon: 'file-new', route: 'createInvoice' }
- { label: 'Invoices', icon: 'file-list', entity: 'Invoice' }
- { label: 'Invoices', icon: 'file-list', entity: 'Invoice', permission: 'ROLE_ACCOUNTANT' }
- { label: 'Payments Received', entity: 'Payment', params: { sortField: 'paidAt' } }
- label: 'About'
children:
- { label: 'Help', route: 'help_index' }
- { label: 'Docs', url: 'http://example.com/external-docs' }
- { label: '%app.version%' }
.. _`valid link types`: https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types
- { label: '%app.version%', permission: 'ROLE_ADMIN' }
-----

Next chapter: :doc:`complex-dynamic-backends`

.. _`valid link types`: https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types
.. _`Symfony security roles`: https://symfony.com/doc/current/security.html#roles
3 changes: 3 additions & 0 deletions src/Configuration/MenuConfigPass.php
Expand Up @@ -108,6 +108,9 @@ private function normalizeMenuConfig(array $menuConfig, array $backendConfig, $p
$itemConfig['rel'] = (string) $itemConfig['rel'];
}

// normalize 'permission' option, which allows to set the security permission needed to see the menu item
$itemConfig['permission'] = $itemConfig['permission'] ?? null;

$menuConfig[$i] = $itemConfig;
}

Expand Down
2 changes: 1 addition & 1 deletion src/Configuration/PropertyConfigPass.php
Expand Up @@ -197,7 +197,7 @@ private function processFieldConfig(array $backendConfig)
// Consider both of them equivalent and copy the 'type_options.help' into 'help'
// to ease further processing of config
if (isset($fieldConfig['help']) && isset($normalizedConfig['type_options']['help'])) {
throw new \RuntimeException(sprintf('The "%s" property in the "%s" view of the "%s" entity defines a help message using both the "help: ..." option from EasyAdmin and the "type_options: { help: ... }" option from Symfony Forms. These two options are equivalent, but you can only define one of them at the same time. Remove one of these two help messages.', $normalizedConfig['property'], $view, $entityName));
throw new \RuntimeException(\sprintf('The "%s" property in the "%s" view of the "%s" entity defines a help message using both the "help: ..." option from EasyAdmin and the "type_options: { help: ... }" option from Symfony Forms. These two options are equivalent, but you can only define one of them at the same time. Remove one of these two help messages.', $normalizedConfig['property'], $view, $entityName));
}

if (isset($normalizedConfig['type_options']['help']) && !isset($fieldConfig['help'])) {
Expand Down
1 change: 1 addition & 0 deletions src/Resources/config/services.xml
Expand Up @@ -47,6 +47,7 @@
<argument>%kernel.debug%</argument>
<argument type="service" id="security.logout_url_generator" on-invalid="null" />
<argument type="service" id="translator" on-invalid="null" />
<argument type="service" id="security.authorization_checker" on-invalid="null" />
<tag name="twig.extension" />
</service>

Expand Down
14 changes: 9 additions & 5 deletions src/Resources/views/default/menu.html.twig
Expand Up @@ -40,23 +40,27 @@
{% block main_menu %}
{% for item in _menu_items %}
{% block menu_item %}
{% set is_selected_menu = app.request.query.get('menuIndex')|default(-1) == loop.index0 %}
{% set is_selected_menu = app.request.query.get('menuIndex')|default(-1) == item.menu_index %}
{% set is_selected_submenu = is_selected_menu and app.request.query.get('submenuIndex')|default(-1) != -1 %}
<li class="{{ item.type == 'divider' ? 'header' }} {{ item.children is not empty ? 'treeview' }} {{ is_selected_menu ? 'active' }} {{ is_selected_submenu ? 'submenu-active' }}">
{% if easyadmin_is_granted(item.permission) %}
<li class="{{ item.type == 'divider' ? 'header' }} {{ item.children is not empty ? 'treeview' }} {{ is_selected_menu ? 'active' }} {{ is_selected_submenu ? 'submenu-active' }}">
{{ helper.render_menu_item(item, _translation_domain) }}

{% if item.children|default([]) is not empty %}
<ul class="treeview-menu">
{% for subitem in item.children %}
{% block menu_subitem %}
<li class="{{ subitem.type == 'divider' ? 'header' }} {{ is_selected_menu and app.request.query.get('submenuIndex')|default(-1) == loop.index0 ? 'active' }}">
{{ helper.render_menu_item(subitem, _translation_domain) }}
</li>
{% if easyadmin_is_granted(subitem.permission) %}
<li class="{{ subitem.type == 'divider' ? 'header' }} {{ is_selected_menu and app.request.query.get('submenuIndex')|default(-1) == subitem.submenu_index ? 'active' }}">
{{ helper.render_menu_item(subitem, _translation_domain) }}
</li>
{% endif %}
{% endblock menu_subitem %}
{% endfor %}
</ul>
{% endif %}
</li>
{% endif %}
{% endblock menu_item %}
{% endfor %}
{% endblock main_menu %}
Expand Down
25 changes: 24 additions & 1 deletion src/Twig/EasyAdminTwigExtension.php
Expand Up @@ -10,6 +10,9 @@
use Symfony\Component\Intl\Exception\MissingResourceException;
use Symfony\Component\Intl\Intl;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Http\Logout\LogoutUrlGenerator;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
Expand All @@ -31,15 +34,17 @@ class EasyAdminTwigExtension extends AbstractExtension
private $logoutUrlGenerator;
/** @var TranslatorInterface|null */
private $translator;
private $authorizationChecker;

public function __construct(ConfigManager $configManager, PropertyAccessorInterface $propertyAccessor, EasyAdminRouter $easyAdminRouter, bool $debug = false, LogoutUrlGenerator $logoutUrlGenerator = null, $translator = null)
public function __construct(ConfigManager $configManager, PropertyAccessorInterface $propertyAccessor, EasyAdminRouter $easyAdminRouter, bool $debug = false, LogoutUrlGenerator $logoutUrlGenerator = null, $translator = null, AuthorizationCheckerInterface $authorizationChecker)
{
$this->configManager = $configManager;
$this->propertyAccessor = $propertyAccessor;
$this->easyAdminRouter = $easyAdminRouter;
$this->debug = $debug;
$this->logoutUrlGenerator = $logoutUrlGenerator;
$this->translator = $translator;
$this->authorizationChecker = $authorizationChecker;
}

/**
Expand All @@ -59,6 +64,7 @@ public function getFunctions()
new TwigFunction('easyadmin_get_actions_for_*_item', [$this, 'getActionsForItem']),
new TwigFunction('easyadmin_logout_path', [$this, 'getLogoutPath']),
new TwigFunction('easyadmin_read_property', [$this, 'readProperty']),
new TwigFunction('easyadmin_is_granted', [$this, 'isGranted']),
];
}

Expand Down Expand Up @@ -469,6 +475,23 @@ public function readProperty($objectOrArray, ?string $propertyPath)
}
}

public function isGranted($permissions, $subject = null): bool
{
// this check is needed for performance reasons because most of the times permissions
// won't be set, so this function must return as early as possible in those cases
if (empty($permissions)) {
return true;
}

try {
return $this->authorizationChecker->isGranted($permissions, $subject);
} catch (AuthenticationCredentialsNotFoundException $e) {
// this exception happens when there's no security configured in the application
// that's a valid scenario for EasyAdmin, where security is not required (although very common)
return true;
}
}

private function getCountryName(?string $countryCode): ?string
{
if (null === $countryCode) {
Expand Down
20 changes: 20 additions & 0 deletions tests/Configuration/fixtures/configurations/input/admin_180.yml
@@ -0,0 +1,20 @@
# TEST
# the 'permission' option for menu items is properly parsed

# CONFIGURATION
easy_admin:
design:
menu:
- label: 'Products'
permission: 'ROLE_PERMISSION_1'
children:
- { entity: 'Product' }
- { entity: 'Product', label: 'Add Product', params: { action: 'new' }, permission: 'ROLE_PERMISSION_2' }
- { label: 'Additional Items' }
- { label: 'Absolute URL', url: 'https://github.com/javiereguiluz/EasyAdminBundle', permission: 'ROLE_PERMISSION_3' }
- { label: 'Categories', entity: 'Category' }
- { label: 'About EasyAdmin', permission: ['ROLE_PERMISSION_4', 'ROLE_PERMISSION_5'] }

entities:
- AppTestBundle\Entity\UnitTests\Category
- AppTestBundle\Entity\UnitTests\Product
18 changes: 18 additions & 0 deletions tests/Configuration/fixtures/configurations/output/config_180.yml
@@ -0,0 +1,18 @@
easy_admin:
design:
menu:
- label: 'Products'
permission: 'ROLE_PERMISSION_1'
children:
- label: 'Product'
permission: null
- label: 'Add Product'
permission: 'ROLE_PERMISSION_2'
- label: 'Additional Items'
permission: null
- label: 'Absolute URL'
permission: 'ROLE_PERMISSION_3'
- label: 'Categories'
permission: null
- label: 'About EasyAdmin'
permission: ['ROLE_PERMISSION_4', 'ROLE_PERMISSION_5']
36 changes: 36 additions & 0 deletions tests/Controller/CustomMenuSecurityTest.php
@@ -0,0 +1,36 @@
<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Controller;

use EasyCorp\Bundle\EasyAdminBundle\Tests\Fixtures\AbstractTestCase;

class CustomMenuSecurityTest extends AbstractTestCase
{
protected static $options = ['environment' => 'custom_menu_security'];

public function testMenuSecurityAsAnonymousUser()
{
$crawler = $this->requestListView();

$this->assertCount(1, $crawler->filter('.sidebar-menu li'));
$this->assertSame('Categories', \trim($crawler->filter('.sidebar-menu li')->text()));
}

public function testMenuSecurityAsLoggedUser()
{
static::$client->followRedirects();
$crawler = static::$client->request('GET', '/admin', [], [], [
'PHP_AUTH_USER' => 'admin',
'PHP_AUTH_PW' => 'pa$$word',
]);

$this->assertCount(7, $crawler->filter('.sidebar-menu li'));
$this->assertSame('Products', \trim($crawler->filter('.sidebar-menu li')->eq(0)->filter('span')->eq(0)->text()));
$this->assertSame('Product', \trim($crawler->filter('.sidebar-menu li')->eq(1)->text()));
$this->assertSame('Add Product', \trim($crawler->filter('.sidebar-menu li')->eq(2)->text()));
$this->assertSame('Additional Items', \trim($crawler->filter('.sidebar-menu li')->eq(3)->text()));
$this->assertSame('Absolute URL', \trim($crawler->filter('.sidebar-menu li')->eq(4)->text()));
$this->assertSame('Categories', \trim($crawler->filter('.sidebar-menu li')->eq(5)->text()));
$this->assertSame('About EasyAdmin', \trim($crawler->filter('.sidebar-menu li')->eq(6)->text()));
}
}
84 changes: 84 additions & 0 deletions tests/Fixtures/App/config/config_custom_menu_security.yml
@@ -0,0 +1,84 @@
# This file cannot import the main ' config.yml' file because it defines its own
# security configuration and 'config.yml' also contains some basic security configuration
# This avoids the following error:
# Symfony\Component\Config\Definition\Exception\InvalidConfigurationException:
# You are not allowed to define new elements for path "security.firewalls".
# Please define all elements for this path in one config file.

imports:
- { resource: services.yml }

parameters:
locale: en
database_path: '%kernel.root_dir%/../../../build/test.db'

framework:
secret: secret
translator: ~
default_locale: '%locale%'
test: ~
router: { resource: "%kernel.root_dir%/config/routing_override.yml" }
form: true
validation: { enable_annotations: true }
profiler:
collect: true
session:
storage_id: session.storage.mock_file

twig:
strict_variables: '%kernel.debug%'

doctrine:
dbal:
driver: pdo_sqlite
path: '%database_path%'
orm:
auto_generate_proxy_classes: true
auto_mapping: true
mappings:
FunctionalTestEntities:
mapping: true
type: annotation
dir: '%kernel.root_dir%/../AppTestBundle/Entity/FunctionalTests/'
alias: 'FunctionalTests'
prefix: 'AppTestBundle\Entity\FunctionalTests'
is_bundle: false

security:
encoders:
Symfony\Component\Security\Core\User\User: plaintext
providers:
in_memory:
memory:
users:
admin:
password: 'pa$$word'
roles: [ROLE_USER, ROLE_ADMIN]
role_hierarchy:
ROLE_ADMIN: ['ROLE_BETA']
firewalls:
main:
pattern: ^/
anonymous: true
logout: true
http_basic:
provider: in_memory
access_control:
- { path: ^/, roles: IS_AUTHENTICATED_ANONYMOUSLY }

easy_admin:
design:
menu:
- label: 'Products'
permission: 'ROLE_BETA'
children:
- { entity: 'Product' }
- { entity: 'Product', label: 'Add Product', params: { action: 'new' }, permission: 'ROLE_ADMIN' }
- { label: 'Additional Items' }
- { label: 'Absolute URL', url: 'https://github.com/javiereguiluz/EasyAdminBundle', permission: ['ROLE_GENERIC_PERMISSION', 'ROLE_USER'] }
- { label: 'Categories', entity: 'Category' }
- { label: 'About EasyAdmin', permission: ['ROLE_ADMIN', 'ROLE_BETA'] }

entities:
- AppTestBundle\Entity\FunctionalTests\Category
- AppTestBundle\Entity\FunctionalTests\Product

0 comments on commit 56f4a82

Please sign in to comment.