Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow to define security permissions for menu items #2806

Merged
merged 1 commit into from Jul 1, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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