From a4a658974d1d98c76212caf4879d5358540d501d Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 18 Mar 2021 17:01:26 +0100 Subject: [PATCH 01/33] AuthBackendController: Inherit from `ipl\Web\Compat\CompatController` --- .../Icinga/Web/Controller/AuthBackendController.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/library/Icinga/Web/Controller/AuthBackendController.php b/library/Icinga/Web/Controller/AuthBackendController.php index f90bd3eea..97dc4b3a9 100644 --- a/library/Icinga/Web/Controller/AuthBackendController.php +++ b/library/Icinga/Web/Controller/AuthBackendController.php @@ -3,19 +3,26 @@ namespace Icinga\Web\Controller; +use ipl\Web\Compat\CompatController; use Zend_Controller_Action_Exception; use Icinga\Application\Config; use Icinga\Authentication\User\UserBackend; use Icinga\Authentication\User\UserBackendInterface; use Icinga\Authentication\UserGroup\UserGroupBackend; use Icinga\Authentication\UserGroup\UserGroupBackendInterface; -use Icinga\Web\Controller; /** * Base class for authentication backend controllers */ -class AuthBackendController extends Controller +class AuthBackendController extends CompatController { + public function init() + { + parent::init(); + + $this->tabs->disableLegacyExtensions(); + } + /** * Redirect to this controller's list action */ From fe7879c68b87ebad8accbd50c03e17cacf93ceb3 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 18 Mar 2021 17:03:12 +0100 Subject: [PATCH 02/33] group|role|user: Add new tab `role/audit` --- application/controllers/GroupController.php | 9 +++++++++ application/controllers/RoleController.php | 9 +++++++++ application/controllers/UserController.php | 9 +++++++++ 3 files changed, 27 insertions(+) diff --git a/application/controllers/GroupController.php b/application/controllers/GroupController.php index 7e3da5ae6..395636f71 100644 --- a/application/controllers/GroupController.php +++ b/application/controllers/GroupController.php @@ -381,6 +381,15 @@ class GroupController extends AuthBackendController 'url' => 'role/list' ) ); + + $tabs->add( + 'role/audit', + [ + 'title' => $this->translate('Audit a user\'s privileges'), + 'label' => $this->translate('Audit'), + 'url' => 'role/audit' + ] + ); } if ($this->hasPermission('config/access-control/users')) { diff --git a/application/controllers/RoleController.php b/application/controllers/RoleController.php index ea35fcf07..fa22efe52 100644 --- a/application/controllers/RoleController.php +++ b/application/controllers/RoleController.php @@ -145,6 +145,15 @@ class RoleController extends AuthBackendController ) ); + $tabs->add( + 'role/audit', + [ + 'title' => $this->translate('Audit a user\'s privileges'), + 'label' => $this->translate('Audit'), + 'url' => 'role/audit' + ] + ); + if ($this->hasPermission('config/access-control/users')) { $tabs->add( 'user/list', diff --git a/application/controllers/UserController.php b/application/controllers/UserController.php index 39b056b1e..f7013777e 100644 --- a/application/controllers/UserController.php +++ b/application/controllers/UserController.php @@ -338,6 +338,15 @@ class UserController extends AuthBackendController 'url' => 'role/list' ) ); + + $tabs->add( + 'role/audit', + [ + 'title' => $this->translate('Audit a user\'s privileges'), + 'label' => $this->translate('Audit'), + 'url' => 'role/audit' + ] + ); } $tabs->add( From 42bdbe38b129df4184d67e613e8296598007594c Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 18 Mar 2021 17:05:04 +0100 Subject: [PATCH 03/33] Introduce class `Icinga\Web\Widget\SingleValueSearchControl` --- .../Web/Widget/SingleValueSearchControl.php | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 library/Icinga/Web/Widget/SingleValueSearchControl.php diff --git a/library/Icinga/Web/Widget/SingleValueSearchControl.php b/library/Icinga/Web/Widget/SingleValueSearchControl.php new file mode 100644 index 000000000..6ab537726 --- /dev/null +++ b/library/Icinga/Web/Widget/SingleValueSearchControl.php @@ -0,0 +1,140 @@ + 'icinga-controls inline']; + + /** @var string */ + protected $searchParameter = self::DEFAULT_SEARCH_PARAMETER; + + /** @var string */ + protected $inputLabel; + + /** @var string */ + protected $submitLabel; + + /** @var Url */ + protected $suggestionUrl; + + /** + * Set the search parameter to use + * + * @param string $name + * @return $this + */ + public function setSearchParameter($name) + { + $this->searchParameter = $name; + + return $this; + } + + /** + * Set the input's label + * + * @param string $label + * + * @return $this + */ + public function setInputLabel($label) + { + $this->inputLabel = $label; + + return $this; + } + + /** + * Set the submit button's label + * + * @param string $label + * + * @return $this + */ + public function setSubmitLabel($label) + { + $this->submitLabel = $label; + + return $this; + } + + /** + * Set the suggestion url + * + * @param Url $url + * + * @return $this + */ + public function setSuggestionUrl(Url $url) + { + $this->suggestionUrl = $url; + + return $this; + } + + protected function assemble() + { + $suggestionsId = Icinga::app()->getRequest()->protectId('single-value-suggestions'); + + $this->addElement( + 'text', + $this->searchParameter, + [ + 'required' => true, + 'minlength' => 1, + 'autocomplete' => 'off', + 'data-enrichment-type' => 'completion', + 'data-term-suggestions' => '#' . $suggestionsId, + 'data-suggest-url' => $this->suggestionUrl, + 'placeholder' => $this->inputLabel + ] + ); + + $this->addElement( + 'submit', + 'btn_sumit', + [ + 'label' => $this->submitLabel, + 'class' => 'btn-primary' + ] + ); + + $this->add(new HtmlElement('div', [ + 'id' => $suggestionsId, + 'class' => 'search-suggestions' + ])); + } + + /** + * Create a list of search suggestions + * + * @param array $values + * + * @return HtmlElement + */ + public static function createSuggestions(array $values) + { + $ul = new HtmlElement('ul'); + + foreach ($values as $value) { + $ul->add(new HtmlElement('li', null, new InputElement(null, [ + 'value' => $value, + 'type' => 'button', + 'tabindex' => -1 + ]))); + } + + return $ul; + } +} From 05fdd98ba81cb6d35eeea924c5b794ff0fe02725 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 18 Mar 2021 17:06:56 +0100 Subject: [PATCH 04/33] role/audit: Add input to choose a user --- application/controllers/RoleController.php | 59 ++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/application/controllers/RoleController.php b/application/controllers/RoleController.php index fa22efe52..dd51e1fc0 100644 --- a/application/controllers/RoleController.php +++ b/application/controllers/RoleController.php @@ -3,11 +3,17 @@ namespace Icinga\Controllers; +use Exception; +use GuzzleHttp\Psr7\ServerRequest; use Icinga\Authentication\RolesConfig; +use Icinga\Data\Selectable; use Icinga\Exception\NotFoundError; use Icinga\Forms\Security\RoleForm; +use Icinga\Repository\Repository; use Icinga\Security\SecurityException; use Icinga\Web\Controller\AuthBackendController; +use Icinga\Web\Widget\SingleValueSearchControl; +use ipl\Web\Url; /** * Manage user permissions and restrictions based on roles @@ -127,6 +133,59 @@ class RoleController extends AuthBackendController $this->renderForm($role, $this->translate('Remove Role')); } + public function auditAction() + { + $this->assertPermission('config/access-control/roles'); + $this->createListTabs()->activate('role/audit'); + $username = $this->params->get('user'); + + $form = new SingleValueSearchControl(); + $form->populate(['q' => $username]); + $form->setInputLabel(t('Enter username')); + $form->setSubmitLabel(t('Inspect')); + $form->setSuggestionUrl(Url::fromPath( + 'role/suggest-role-member', + ['_disableLayout' => true, 'showCompact' => true] + )); + + $form->on(SingleValueSearchControl::ON_SUCCESS, function ($form) { + $this->redirectNow(Url::fromPath('role/audit', ['user' => $form->getValue('q')])); + })->handleRequest(ServerRequest::fromGlobals()); + + $this->content->add($form); + } + + public function suggestRoleMemberAction() + { + $this->assertHttpMethod('POST'); + $requestData = $this->getRequest()->getPost(); + $limit = $this->params->get('limit', 50); + + $searchTerm = $requestData['term']['label']; + $backends = $this->loadUserBackends(Selectable::class); + + $users = []; + while ($limit > 0 && ! empty($backends)) { + /** @var Repository $backend */ + $backend = array_shift($backends); + $query = $backend->select() + ->from('user', ['user_name']) + ->where('user_name', $searchTerm) + ->limit($limit); + + try { + $names = $query->fetchColumn(); + } catch (Exception $e) { + continue; + } + + array_push($users, ...$names); + $limit -= count($names); + } + + $this->document->add(SingleValueSearchControl::createSuggestions($users)); + } + /** * Create the tabs to display when listing roles */ From 153e9b4ade74352265e9db48aeaa0eab64e266aa Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 19 Mar 2021 17:11:13 +0100 Subject: [PATCH 05/33] SingleValueSearchControl: Add support for groups and meta data --- .../Web/Widget/SingleValueSearchControl.php | 65 ++++++++++++++++--- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/library/Icinga/Web/Widget/SingleValueSearchControl.php b/library/Icinga/Web/Widget/SingleValueSearchControl.php index 6ab537726..8df26a9af 100644 --- a/library/Icinga/Web/Widget/SingleValueSearchControl.php +++ b/library/Icinga/Web/Widget/SingleValueSearchControl.php @@ -7,6 +7,7 @@ use Icinga\Application\Icinga; use ipl\Html\Form; use ipl\Html\FormElement\InputElement; use ipl\Html\HtmlElement; +use ipl\Web\Control\SearchBar\Suggestions; use ipl\Web\Url; class SingleValueSearchControl extends Form @@ -28,6 +29,9 @@ class SingleValueSearchControl extends Form /** @var Url */ protected $suggestionUrl; + /** @var array */ + protected $metaDataNames; + /** * Set the search parameter to use * @@ -83,6 +87,20 @@ class SingleValueSearchControl extends Form return $this; } + /** + * Set names for which hidden meta data elements should be created + * + * @param string ...$names + * + * @return $this + */ + public function setMetaDataNames(...$names) + { + $this->metaDataNames = $names; + + return $this; + } + protected function assemble() { $suggestionsId = Icinga::app()->getRequest()->protectId('single-value-suggestions'); @@ -101,6 +119,17 @@ class SingleValueSearchControl extends Form ] ); + if (! empty($this->metaDataNames)) { + $fieldset = new HtmlElement('fieldset'); + foreach ($this->metaDataNames as $name) { + $hiddenElement = $this->createElement('hidden', $this->searchParameter . '-' . $name); + $this->registerElement($hiddenElement); + $fieldset->add($hiddenElement); + } + + $this->getElement($this->searchParameter)->prependWrapper($fieldset); + } + $this->addElement( 'submit', 'btn_sumit', @@ -117,22 +146,40 @@ class SingleValueSearchControl extends Form } /** - * Create a list of search suggestions + * Create a list of search suggestions based on the given groups * - * @param array $values + * @param array $groups * * @return HtmlElement */ - public static function createSuggestions(array $values) + public static function createSuggestions(array $groups) { $ul = new HtmlElement('ul'); + foreach ($groups as $name => $entries) { + if (is_string($name)) { + if ($entries === false) { + $ul->add(new HtmlElement('li', ['class' => 'failure-message'], [ + new HtmlElement('em', null, t('Can\'t search:')), + $name + ])); + continue; + } else { + $ul->add(new HtmlElement('li', ['class' => Suggestions::SUGGESTION_TITLE_CLASS], $name)); + } + } - foreach ($values as $value) { - $ul->add(new HtmlElement('li', null, new InputElement(null, [ - 'value' => $value, - 'type' => 'button', - 'tabindex' => -1 - ]))); + foreach ($entries as $label => $metaData) { + $attributes = [ + 'value' => $label, + 'type' => 'button', + 'tabindex' => -1 + ]; + foreach ($metaData as $key => $value) { + $attributes['data-' . $key] = $value; + } + + $ul->add(new HtmlElement('li', null, new InputElement(null, $attributes))); + } } return $ul; From 1fbd76ef69b02a3128bec68d04d79382905c37a5 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 19 Mar 2021 17:12:49 +0100 Subject: [PATCH 06/33] role/audit: Also allow to audit groups --- application/controllers/GroupController.php | 2 +- application/controllers/RoleController.php | 61 +++++++++++++++++---- application/controllers/UserController.php | 2 +- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/application/controllers/GroupController.php b/application/controllers/GroupController.php index 395636f71..059f98739 100644 --- a/application/controllers/GroupController.php +++ b/application/controllers/GroupController.php @@ -385,7 +385,7 @@ class GroupController extends AuthBackendController $tabs->add( 'role/audit', [ - 'title' => $this->translate('Audit a user\'s privileges'), + 'title' => $this->translate('Audit a user\'s or group\'s privileges'), 'label' => $this->translate('Audit'), 'url' => 'role/audit' ] diff --git a/application/controllers/RoleController.php b/application/controllers/RoleController.php index dd51e1fc0..a58f4754e 100644 --- a/application/controllers/RoleController.php +++ b/application/controllers/RoleController.php @@ -137,11 +137,14 @@ class RoleController extends AuthBackendController { $this->assertPermission('config/access-control/roles'); $this->createListTabs()->activate('role/audit'); - $username = $this->params->get('user'); + + $type = $this->params->has('group') ? 'group' : 'user'; + $name = $this->params->get($type); $form = new SingleValueSearchControl(); - $form->populate(['q' => $username]); - $form->setInputLabel(t('Enter username')); + $form->setMetaDataNames('type'); + $form->populate(['q' => $name, 'q-type' => $type]); + $form->setInputLabel(t('Enter user or group name')); $form->setSubmitLabel(t('Inspect')); $form->setSuggestionUrl(Url::fromPath( 'role/suggest-role-member', @@ -149,7 +152,9 @@ class RoleController extends AuthBackendController )); $form->on(SingleValueSearchControl::ON_SUCCESS, function ($form) { - $this->redirectNow(Url::fromPath('role/audit', ['user' => $form->getValue('q')])); + $this->redirectNow(Url::fromPath('role/audit', [ + $form->getValue('q-type') ?: 'user' => $form->getValue('q') + ])); })->handleRequest(ServerRequest::fromGlobals()); $this->content->add($form); @@ -162,12 +167,12 @@ class RoleController extends AuthBackendController $limit = $this->params->get('limit', 50); $searchTerm = $requestData['term']['label']; - $backends = $this->loadUserBackends(Selectable::class); + $userBackends = $this->loadUserBackends(Selectable::class); $users = []; - while ($limit > 0 && ! empty($backends)) { + while ($limit > 0 && ! empty($userBackends)) { /** @var Repository $backend */ - $backend = array_shift($backends); + $backend = array_shift($userBackends); $query = $backend->select() ->from('user', ['user_name']) ->where('user_name', $searchTerm) @@ -179,11 +184,47 @@ class RoleController extends AuthBackendController continue; } - array_push($users, ...$names); + foreach ($names as $name) { + $users[$name] = ['type' => 'user']; + } + $limit -= count($names); } - $this->document->add(SingleValueSearchControl::createSuggestions($users)); + $groupBackends = $this->loadUserGroupBackends(Selectable::class); + + $groups = []; + while ($limit > 0 && ! empty($groupBackends)) { + /** @var Repository $backend */ + $backend = array_shift($groupBackends); + $query = $backend->select() + ->from('group', ['group_name']) + ->where('group_name', $searchTerm) + ->limit($limit); + + try { + $names = $query->fetchColumn(); + } catch (Exception $e) { + continue; + } + + foreach ($names as $name) { + $groups[$name] = ['type' => 'group']; + } + + $limit -= count($names); + } + + $suggestions = []; + if (! empty($users)) { + $suggestions[t('Users')] = $users; + } + + if (! empty($groups)) { + $suggestions[t('Groups')] = $groups; + } + + $this->document->add(SingleValueSearchControl::createSuggestions($suggestions)); } /** @@ -207,7 +248,7 @@ class RoleController extends AuthBackendController $tabs->add( 'role/audit', [ - 'title' => $this->translate('Audit a user\'s privileges'), + 'title' => $this->translate('Audit a user\'s or group\'s privileges'), 'label' => $this->translate('Audit'), 'url' => 'role/audit' ] diff --git a/application/controllers/UserController.php b/application/controllers/UserController.php index f7013777e..1f91a55bf 100644 --- a/application/controllers/UserController.php +++ b/application/controllers/UserController.php @@ -342,7 +342,7 @@ class UserController extends AuthBackendController $tabs->add( 'role/audit', [ - 'title' => $this->translate('Audit a user\'s privileges'), + 'title' => $this->translate('Audit a user\'s or group\'s privileges'), 'label' => $this->translate('Audit'), 'url' => 'role/audit' ] From 0aa4e2572373033943dc022ef56a6f938796f9d8 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Mon, 22 Mar 2021 12:26:31 +0100 Subject: [PATCH 07/33] Auth: Introduce method `setupUser()` This was previously part of method `setAuthenticated()`. Split up to allow external usage. --- library/Icinga/Authentication/Auth.php | 193 ++++++++++++++----------- 1 file changed, 109 insertions(+), 84 deletions(-) diff --git a/library/Icinga/Authentication/Auth.php b/library/Icinga/Authentication/Auth.php index 28d2e0ba1..4deccc83b 100644 --- a/library/Icinga/Authentication/Auth.php +++ b/library/Icinga/Authentication/Auth.php @@ -98,97 +98,16 @@ class Auth public function setAuthenticated(User $user, $persist = true) { - $username = $user->getUsername(); - try { - $config = Config::app(); - } catch (NotReadableError $e) { - Logger::error( - new IcingaException( - 'Cannot load preferences for user "%s". An exception was thrown: %s', - $username, - $e - ) - ); - $config = new Config(); - } - if ($config->get('global', 'config_backend', 'db') !== 'none') { - $preferencesConfig = new ConfigObject(array( - 'store' => $config->get('global', 'config_backend', 'db'), - 'resource' => $config->get('global', 'config_resource') - )); - try { - $preferencesStore = PreferencesStore::create( - $preferencesConfig, - $user - ); - $preferences = new Preferences($preferencesStore->load()); - } catch (Exception $e) { - Logger::error( - new IcingaException( - 'Cannot load preferences for user "%s". An exception was thrown: %s', - $username, - $e - ) - ); - $preferences = new Preferences(); - } - } else { - $preferences = new Preferences(); - } + $this->setupUser($user); + // TODO(el): Quick-fix for #10957. Only reload CSS if the theme changed. $this->getResponse()->setReloadCss(true); - $user->setPreferences($preferences); - $groups = $user->getGroups(); - $userBackendName = $user->getAdditional('backend_name'); - foreach (Config::app('groups') as $name => $config) { - $groupsUserBackend = $config->user_backend; - if ($groupsUserBackend - && $groupsUserBackend !== 'none' - && $userBackendName !== null - && $groupsUserBackend !== $userBackendName - ) { - // Do not ask for Group membership if a specific User Backend - // has been assigned to that Group Backend, and the user has - // been authenticated by another User Backend - continue; - } - try { - $groupBackend = UserGroupBackend::create($name, $config); - $groupsFromBackend = $groupBackend->getMemberships($user); - } catch (Exception $e) { - Logger::error( - 'Can\'t get group memberships for user \'%s\' from backend \'%s\'. An exception was thrown: %s', - $username, - $name, - $e - ); - continue; - } - if (empty($groupsFromBackend)) { - Logger::debug( - 'No groups found in backend "%s" which the user "%s" is a member of.', - $name, - $user->getUsername() - ); - continue; - } - $groupsFromBackend = array_values($groupsFromBackend); - Logger::debug( - 'Groups found in backend "%s" for user "%s": %s', - $name, - $user->getUsername(), - join(', ', $groupsFromBackend) - ); - $groups = array_merge($groups, array_combine($groupsFromBackend, $groupsFromBackend)); - } - $user->setGroups($groups); - $admissionLoader = new AdmissionLoader(); - $admissionLoader->applyRoles($user); $this->user = $user; if ($persist) { $this->persistCurrentUser(); } + AuditHook::logActivity('login', 'User logged in'); } @@ -409,4 +328,110 @@ class Auth $this->user = null; Session::getSession()->purge(); } + + /** + * Setup the given user + * + * This loads preferences, groups and roles. + * + * @param User $user + * + * @return void + */ + public function setupUser(User $user) + { + // Load the user's preferences + + try { + $config = Config::app(); + } catch (NotReadableError $e) { + Logger::error( + new IcingaException( + 'Cannot load preferences for user "%s". An exception was thrown: %s', + $user->getUsername(), + $e + ) + ); + $config = new Config(); + } + + if ($config->get('global', 'config_backend', 'db') !== 'none') { + $preferencesConfig = new ConfigObject([ + 'store' => $config->get('global', 'config_backend', 'db'), + 'resource' => $config->get('global', 'config_resource') + ]); + + try { + $preferencesStore = PreferencesStore::create($preferencesConfig, $user); + $preferences = new Preferences($preferencesStore->load()); + } catch (Exception $e) { + Logger::error( + new IcingaException( + 'Cannot load preferences for user "%s". An exception was thrown: %s', + $user->getUsername(), + $e + ) + ); + $preferences = new Preferences(); + } + } else { + $preferences = new Preferences(); + } + + $user->setPreferences($preferences); + + // Load the user's groups + $groups = $user->getGroups(); + $userBackendName = $user->getAdditional('backend_name'); + foreach (Config::app('groups') as $name => $config) { + $groupsUserBackend = $config->user_backend; + if ($groupsUserBackend + && $groupsUserBackend !== 'none' + && $userBackendName !== null + && $groupsUserBackend !== $userBackendName + ) { + // Do not ask for Group membership if a specific User Backend + // has been assigned to that Group Backend, and the user has + // been authenticated by another User Backend + continue; + } + + try { + $groupBackend = UserGroupBackend::create($name, $config); + $groupsFromBackend = $groupBackend->getMemberships($user); + } catch (Exception $e) { + Logger::error( + 'Can\'t get group memberships for user \'%s\' from backend \'%s\'. An exception was thrown: %s', + $user->getUsername(), + $name, + $e + ); + continue; + } + + if (empty($groupsFromBackend)) { + Logger::debug( + 'No groups found in backend "%s" which the user "%s" is a member of.', + $name, + $user->getUsername() + ); + continue; + } + + $groupsFromBackend = array_values($groupsFromBackend); + Logger::debug( + 'Groups found in backend "%s" for user "%s": %s', + $name, + $user->getUsername(), + join(', ', $groupsFromBackend) + ); + $groups = array_merge($groups, array_combine($groupsFromBackend, $groupsFromBackend)); + } + + $user->setGroups($groups); + + // Load the user's roles + $admissionLoader = new AdmissionLoader(); + $admissionLoader->applyRoles($user); + } } From f4da973f688c484f586eb416619dbac3aa23f3a9 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Mon, 22 Mar 2021 12:28:30 +0100 Subject: [PATCH 08/33] Auth: Only reload CSS upon login if the theme **really** changed fixes #2233 --- library/Icinga/Authentication/Auth.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/library/Icinga/Authentication/Auth.php b/library/Icinga/Authentication/Auth.php index 4deccc83b..1fa4887be 100644 --- a/library/Icinga/Authentication/Auth.php +++ b/library/Icinga/Authentication/Auth.php @@ -17,6 +17,7 @@ use Icinga\User; use Icinga\User\Preferences; use Icinga\User\Preferences\PreferencesStore; use Icinga\Web\Session; +use Icinga\Web\StyleSheet; class Auth { @@ -100,8 +101,15 @@ class Auth { $this->setupUser($user); - // TODO(el): Quick-fix for #10957. Only reload CSS if the theme changed. - $this->getResponse()->setReloadCss(true); + // Reload CSS if the theme changed + $themingConfig = Icinga::app()->getConfig()->getSection('themes'); + $userTheme = $user->getPreferences()->getValue('icingaweb', 'theme'); + if (! (bool) $themingConfig->get('disabled', false) && $userTheme !== null) { + $defaultTheme = $themingConfig->get('default', StyleSheet::DEFAULT_THEME); + if ($userTheme !== $defaultTheme) { + $this->getResponse()->setReloadCss(true); + } + } $this->user = $user; if ($persist) { From 8ff88cd6f17e7706f8d9f89ca0abab4dc5e1a42b Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Mon, 22 Mar 2021 13:30:38 +0100 Subject: [PATCH 09/33] role/audit: Require a backend name for user audits --- application/controllers/RoleController.php | 25 ++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/application/controllers/RoleController.php b/application/controllers/RoleController.php index a58f4754e..000017f0d 100644 --- a/application/controllers/RoleController.php +++ b/application/controllers/RoleController.php @@ -141,9 +141,14 @@ class RoleController extends AuthBackendController $type = $this->params->has('group') ? 'group' : 'user'; $name = $this->params->get($type); + $backend = null; + if ($name && $type === 'user') { + $backend = $this->params->getRequired('backend'); + } + $form = new SingleValueSearchControl(); - $form->setMetaDataNames('type'); - $form->populate(['q' => $name, 'q-type' => $type]); + $form->setMetaDataNames('type', 'backend'); + $form->populate(['q' => $name, 'q-type' => $type, 'q-backend' => $backend]); $form->setInputLabel(t('Enter user or group name')); $form->setSubmitLabel(t('Inspect')); $form->setSuggestionUrl(Url::fromPath( @@ -152,9 +157,14 @@ class RoleController extends AuthBackendController )); $form->on(SingleValueSearchControl::ON_SUCCESS, function ($form) { - $this->redirectNow(Url::fromPath('role/audit', [ - $form->getValue('q-type') ?: 'user' => $form->getValue('q') - ])); + $type = $form->getValue('q-type') ?: 'user'; + $params = [$type => $form->getValue('q')]; + + if ($type === 'user') { + $params['backend'] = $form->getValue('q-backend'); + } + + $this->redirectNow(Url::fromPath('role/audit', $params)); })->handleRequest(ServerRequest::fromGlobals()); $this->content->add($form); @@ -185,7 +195,10 @@ class RoleController extends AuthBackendController } foreach ($names as $name) { - $users[$name] = ['type' => 'user']; + $users[$name] = [ + 'type' => 'user', + 'backend' => $backend->getName() + ]; } $limit -= count($names); From 9d10424f9706bc015356e0c3c9a1195ae26a6c35 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Mon, 22 Mar 2021 15:21:36 +0100 Subject: [PATCH 10/33] AdmissionLoader: Set additional user information `assigned_roles` --- library/Icinga/Authentication/AdmissionLoader.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/library/Icinga/Authentication/AdmissionLoader.php b/library/Icinga/Authentication/AdmissionLoader.php index c1f8af64a..e42eb2ece 100644 --- a/library/Icinga/Authentication/AdmissionLoader.php +++ b/library/Icinga/Authentication/AdmissionLoader.php @@ -179,9 +179,15 @@ class AdmissionLoader $roles = []; $permissions = []; $restrictions = []; + $assignedRoles = []; $isUnrestricted = false; foreach ($this->roleConfig as $roleName => $roleConfig) { - if (! isset($roles[$roleName]) && $this->match($username, $userGroups, $roleConfig)) { + $assigned = $this->match($username, $userGroups, $roleConfig); + if ($assigned) { + $assignedRoles[] = $roleName; + } + + if (! isset($roles[$roleName]) && $assigned) { foreach ($this->loadRole($roleName, $roleConfig) as $role) { /** @var Role $role */ $roles[$role->getName()] = $role; @@ -206,6 +212,8 @@ class AdmissionLoader } } + $user->setAdditional('assigned_roles', $assignedRoles); + $user->setIsUnrestricted($isUnrestricted); $user->setRestrictions($isUnrestricted ? [] : $restrictions); $user->setPermissions($permissions); From ab7d73a8ee874f7e66b836e67e5275bde6bd6239 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Mon, 22 Mar 2021 16:24:21 +0100 Subject: [PATCH 11/33] RoleForm: Add static method `collectProvidedPrivileges()` Was previously part of method `init()`. Split up into its own method to allow external usage. --- application/forms/Security/RoleForm.php | 297 +++++++++++------------- 1 file changed, 140 insertions(+), 157 deletions(-) diff --git a/application/forms/Security/RoleForm.php b/application/forms/Security/RoleForm.php index a2eaf0ea3..4afd48b8c 100644 --- a/application/forms/Security/RoleForm.php +++ b/application/forms/Security/RoleForm.php @@ -12,7 +12,6 @@ use Icinga\Forms\ConfigForm; use Icinga\Forms\RepositoryForm; use Icinga\Util\StringHelper; use Icinga\Web\Notification; -use Zend_Form_Element; /** * Form for managing roles @@ -47,133 +46,7 @@ class RoleForm extends RepositoryForm { $this->setAttrib('class', self::DEFAULT_CLASSES . ' role-form'); - $helper = new Zend_Form_Element('bogus'); - $view = $this->getView(); - - $this->providedPermissions['application'] = [ - $helper->filterName('application/announcements') => [ - 'name' => 'application/announcements', - 'description' => $this->translate('Allow to manage announcements') - ], - $helper->filterName('application/log') => [ - 'name' => 'application/log', - 'description' => $this->translate('Allow to view the application log') - ], - $helper->filterName('config/*') => [ - 'name' => 'config/*', - 'description' => $this->translate('Allow full config access') - ], - $helper->filterName('config/general') => [ - 'name' => 'config/general', - 'description' => $this->translate('Allow to adjust the general configuration') - ], - $helper->filterName('config/modules') => [ - 'name' => 'config/modules', - 'description' => $this->translate('Allow to enable/disable and configure modules') - ], - $helper->filterName('config/resources') => [ - 'name' => 'config/resources', - 'description' => $this->translate('Allow to manage resources') - ], - $helper->filterName('config/navigation') => [ - 'name' => 'config/navigation', - 'description' => $this->translate('Allow to view and adjust shared navigation items') - ], - $helper->filterName('config/access-control/*') => [ - 'name' => 'config/access-control/*', - 'description' => $this->translate('Allow to fully manage access-control') - ], - $helper->filterName('config/access-control/users') => [ - 'name' => 'config/access-control/users', - 'description' => $this->translate('Allow to manage user accounts') - ], - $helper->filterName('config/access-control/groups') => [ - 'name' => 'config/access-control/groups', - 'description' => $this->translate('Allow to manage user groups') - ], - $helper->filterName('config/access-control/roles') => [ - 'name' => 'config/access-control/roles', - 'description' => $this->translate('Allow to manage roles') - ], - $helper->filterName('user/*') => [ - 'name' => 'user/*', - 'description' => $this->translate('Allow all account related functionalities') - ], - $helper->filterName('user/password-change') => [ - 'name' => 'user/password-change', - 'description' => $this->translate('Allow password changes in the account preferences') - ], - $helper->filterName('user/application/stacktraces') => [ - 'name' => 'user/application/stacktraces', - 'description' => $this->translate( - 'Allow to adjust in the preferences whether to show stacktraces' - ) - ], - $helper->filterName('user/share/navigation') => [ - 'name' => 'user/share/navigation', - 'description' => $this->translate('Allow to share navigation items') - ] - ]; - - $this->providedRestrictions['application'] = [ - $helper->filterName('application/share/users') => [ - 'name' => 'application/share/users', - 'description' => $this->translate( - 'Restrict which users this role can share items and information with' - ) - ], - $helper->filterName('application/share/groups') => [ - 'name' => 'application/share/groups', - 'description' => $this->translate( - 'Restrict which groups this role can share items and information with' - ) - ] - ]; - - $mm = Icinga::app()->getModuleManager(); - foreach ($mm->listInstalledModules() as $moduleName) { - $modulePermission = Manager::MODULE_PERMISSION_NS . $moduleName; - $this->providedPermissions[$moduleName][$helper->filterName($modulePermission)] = [ - 'isUsagePerm' => true, - 'name' => $modulePermission, - 'label' => $view->escape($this->translate('General Module Access')), - 'description' => sprintf($this->translate('Allow access to module %s'), $moduleName) - ]; - - $module = $mm->getModule($moduleName, false); - $permissions = $module->getProvidedPermissions(); - - $this->providedPermissions[$moduleName][$helper->filterName($moduleName . '/*')] = [ - 'isFullPerm' => true, - 'name' => $moduleName . '/*', - 'label' => $view->escape($this->translate('Full Module Access')) - ]; - - foreach ($permissions as $permission) { - /** @var object $permission */ - $this->providedPermissions[$moduleName][$helper->filterName($permission->name)] = [ - 'name' => $permission->name, - 'label' => preg_replace( - '~^(\w+)(\/.*)~', - '$1$2', - $view->escape($permission->name) - ), - 'description' => $permission->description - ]; - } - - foreach ($module->getProvidedRestrictions() as $restriction) { - $this->providedRestrictions[$moduleName][$helper->filterName($restriction->name)] = [ - 'name' => $restriction->name, - 'label' => preg_replace( - '~^(\w+)(\/.*)~', - '$1$2', - $view->escape($restriction->name) - ), - 'description' => $restriction->description - ]; - } - } + list($this->providedPermissions, $this->providedRestrictions) = static::collectProvidedPrivileges(); } protected function createFilter() @@ -273,14 +146,14 @@ class RoleForm extends RepositoryForm $hasFullPerm = false; foreach ($permissionList as $name => $spec) { - $elementName = $name; + $elementName = $this->filterName($name); if ($hasFullPerm || $hasAdminPerm) { $elementName .= '_fake'; } $denyCheckbox = null; if (! isset($spec['isFullPerm']) - && substr($spec['name'], 0, strlen(self::DENY_PREFIX)) !== self::DENY_PREFIX + && substr($name, 0, strlen(self::DENY_PREFIX)) !== self::DENY_PREFIX ) { $denyCheckbox = $this->createElement('checkbox', self::DENY_PREFIX . $name, [ 'decorators' => ['ViewHelper'] @@ -302,9 +175,13 @@ class RoleForm extends RepositoryForm // Adds a zero-width char after each slash to help browsers break onto newlines '~(?$1$2', + $name + ) ), - 'description' => isset($spec['description']) ? $spec['description'] : $spec['name'], + 'description' => isset($spec['description']) ? $spec['description'] : $name, 'decorators' => array_merge( array_slice(self::$defaultElementDecorators, 0, 3), [['Callback', ['callback' => function () use ($denyCheckbox) { @@ -320,11 +197,12 @@ class RoleForm extends RepositoryForm if ($hasFullPerm || $hasAdminPerm) { // Add a hidden element to preserve the configured permission value - $this->addElement('hidden', $name); + $this->addElement('hidden', $this->filterName($name)); } if (isset($spec['isFullPerm'])) { - $hasFullPerm = isset($formData[$name]) && $formData[$name]; + $filteredName = $this->filterName($name); + $hasFullPerm = isset($formData[$filteredName]) && $formData[$filteredName]; } } @@ -336,23 +214,28 @@ class RoleForm extends RepositoryForm ]); foreach ($this->providedRestrictions[$moduleName] as $name => $spec) { - $elements[] = $name; + $elementName = $this->filterName($name); + $elements[] = $elementName; $this->addElement( 'text', - $name, + $elementName, [ 'label' => preg_replace( // Adds a zero-width char after each slash to help browsers break onto newlines '~(?$1$2', + $name + ) ), 'description' => $spec['description'], 'style' => $isUnrestricted ? 'text-decoration:line-through;' : '', 'readonly' => $isUnrestricted ?: null ] ) - ->getElement($name) + ->getElement($elementName) ->getDecorator('Label') ->setOption('escape', false); } @@ -402,11 +285,11 @@ class RoleForm extends RepositoryForm foreach ($this->providedPermissions as $moduleName => $permissionList) { foreach ($permissionList as $name => $spec) { - if (in_array($spec['name'], $permissions, true)) { - $values[$name] = 1; + if (in_array($name, $permissions, true)) { + $values[$this->filterName($name)] = 1; } - if (in_array($spec['name'], $refusals, true)) { + if (in_array($name, $refusals, true)) { $values[$this->filterName(self::DENY_PREFIX . $name)] = 1; } } @@ -415,8 +298,8 @@ class RoleForm extends RepositoryForm foreach ($this->providedRestrictions as $moduleName => $restrictionList) { foreach ($restrictionList as $name => $spec) { - if (isset($role->{$spec['name']})) { - $values[$name] = $role->{$spec['name']}; + if (isset($role->$name)) { + $values[$this->filterName($name)] = $role->$name; } } } @@ -430,9 +313,10 @@ class RoleForm extends RepositoryForm foreach ($this->providedRestrictions as $moduleName => $restrictionList) { foreach ($restrictionList as $name => $spec) { - if (isset($values[$name])) { - $values[$spec['name']] = $values[$name]; - unset($values[$name]); + $elementName = $this->filterName($name); + if (isset($values[$elementName])) { + $values[$name] = $values[$elementName]; + unset($values[$elementName]); } } } @@ -445,16 +329,17 @@ class RoleForm extends RepositoryForm $refusals = []; foreach ($this->providedPermissions as $moduleName => $permissionList) { foreach ($permissionList as $name => $spec) { - if (isset($values[$name]) && $values[$name]) { - $permissions[] = $spec['name']; + $elementName = $this->filterName($name); + if (isset($values[$elementName]) && $values[$elementName]) { + $permissions[] = $name; } $denyName = $this->filterName(self::DENY_PREFIX . $name); if (isset($values[$denyName]) && $values[$denyName]) { - $refusals[] = $spec['name']; + $refusals[] = $name; } - unset($values[$name], $values[$denyName]); + unset($values[$elementName], $values[$denyName]); } } @@ -481,15 +366,15 @@ class RoleForm extends RepositoryForm protected function sortPermissions(&$permissions) { - return uasort($permissions, function ($a, $b) { - if (isset($a['isUsagePerm'])) { - return isset($b['isFullPerm']) ? 1 : -1; - } elseif (isset($b['isUsagePerm'])) { - return isset($a['isFullPerm']) ? -1 : 1; + return uksort($permissions, function ($a, $b) use ($permissions) { + if (isset($permissions[$a]['isUsagePerm'])) { + return isset($permissions[$b]['isFullPerm']) ? 1 : -1; + } elseif (isset($permissions[$b]['isUsagePerm'])) { + return isset($permissions[$a]['isFullPerm']) ? -1 : 1; } - $aParts = explode('/', $a['name']); - $bParts = explode('/', $b['name']); + $aParts = explode('/', $a); + $bParts = explode('/', $b); do { $a = array_shift($aParts); @@ -566,4 +451,102 @@ class RoleForm extends RepositoryForm )); } } + + /** + * Collect permissions and restrictions provided by Icinga Web 2 and modules + * + * @return array[$permissions, $restrictions] + */ + public static function collectProvidedPrivileges() + { + $providedPermissions['application'] = [ + 'application/announcements' => [ + 'description' => t('Allow to manage announcements') + ], + 'application/log' => [ + 'description' => t('Allow to view the application log') + ], + 'config/*' => [ + 'description' => t('Allow full config access') + ], + 'config/general' => [ + 'description' => t('Allow to adjust the general configuration') + ], + 'config/modules' => [ + 'description' => t('Allow to enable/disable and configure modules') + ], + 'config/resources' => [ + 'description' => t('Allow to manage resources') + ], + 'config/navigation' => [ + 'description' => t('Allow to view and adjust shared navigation items') + ], + 'config/access-control/*' => [ + 'description' => t('Allow to fully manage access-control') + ], + 'config/access-control/users' => [ + 'description' => t('Allow to manage user accounts') + ], + 'config/access-control/groups' => [ + 'description' => t('Allow to manage user groups') + ], + 'config/access-control/roles' => [ + 'description' => t('Allow to manage roles') + ], + 'user/*' => [ + 'description' => t('Allow all account related functionalities') + ], + 'user/password-change' => [ + 'description' => t('Allow password changes in the account preferences') + ], + 'user/application/stacktraces' => [ + 'description' => t('Allow to adjust in the preferences whether to show stacktraces') + ], + 'user/share/navigation' => [ + 'description' => t('Allow to share navigation items') + ] + ]; + + $providedRestrictions['application'] = [ + 'application/share/users' => [ + 'description' => t('Restrict which users this role can share items and information with') + ], + 'application/share/groups' => [ + 'description' => t('Restrict which groups this role can share items and information with') + ] + ]; + + $mm = Icinga::app()->getModuleManager(); + foreach ($mm->listInstalledModules() as $moduleName) { + $modulePermission = Manager::MODULE_PERMISSION_NS . $moduleName; + $providedPermissions[$moduleName][$modulePermission] = [ + 'isUsagePerm' => true, + 'label' => t('General Module Access'), + 'description' => sprintf(t('Allow access to module %s'), $moduleName) + ]; + + $module = $mm->getModule($moduleName, false); + $permissions = $module->getProvidedPermissions(); + + $providedPermissions[$moduleName][$moduleName . '/*'] = [ + 'isFullPerm' => true, + 'label' => t('Full Module Access') + ]; + + foreach ($permissions as $permission) { + /** @var object $permission */ + $providedPermissions[$moduleName][$permission->name] = [ + 'description' => $permission->description + ]; + } + + foreach ($module->getProvidedRestrictions() as $restriction) { + $providedRestrictions[$moduleName][$restriction->name] = [ + 'description' => $restriction->description + ]; + } + } + + return [$providedPermissions, $providedRestrictions]; + } } From c203ffdd79f5231893423ce2e082a5e0c8fdcd28 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Mon, 22 Mar 2021 16:34:51 +0100 Subject: [PATCH 12/33] role|user|group: Open `Audit` tab always in `#col1` and close `#col2` --- application/controllers/GroupController.php | 7 ++++--- application/controllers/RoleController.php | 7 ++++--- application/controllers/UserController.php | 7 ++++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/application/controllers/GroupController.php b/application/controllers/GroupController.php index 059f98739..d18397cf4 100644 --- a/application/controllers/GroupController.php +++ b/application/controllers/GroupController.php @@ -385,9 +385,10 @@ class GroupController extends AuthBackendController $tabs->add( 'role/audit', [ - 'title' => $this->translate('Audit a user\'s or group\'s privileges'), - 'label' => $this->translate('Audit'), - 'url' => 'role/audit' + 'title' => $this->translate('Audit a user\'s or group\'s privileges'), + 'label' => $this->translate('Audit'), + 'url' => 'role/audit', + 'baseTarget' => '_main', ] ); } diff --git a/application/controllers/RoleController.php b/application/controllers/RoleController.php index 000017f0d..c0df218ab 100644 --- a/application/controllers/RoleController.php +++ b/application/controllers/RoleController.php @@ -261,9 +261,10 @@ class RoleController extends AuthBackendController $tabs->add( 'role/audit', [ - 'title' => $this->translate('Audit a user\'s or group\'s privileges'), - 'label' => $this->translate('Audit'), - 'url' => 'role/audit' + 'title' => $this->translate('Audit a user\'s or group\'s privileges'), + 'label' => $this->translate('Audit'), + 'url' => 'role/audit', + 'baseTarget' => '_main' ] ); diff --git a/application/controllers/UserController.php b/application/controllers/UserController.php index 1f91a55bf..b011cdfb4 100644 --- a/application/controllers/UserController.php +++ b/application/controllers/UserController.php @@ -342,9 +342,10 @@ class UserController extends AuthBackendController $tabs->add( 'role/audit', [ - 'title' => $this->translate('Audit a user\'s or group\'s privileges'), - 'label' => $this->translate('Audit'), - 'url' => 'role/audit' + 'title' => $this->translate('Audit a user\'s or group\'s privileges'), + 'label' => $this->translate('Audit'), + 'url' => 'role/audit', + 'baseTarget' => '_main' ] ); } From ab90b3e0a1f21be9c35d0bb6498af8a3f322f6e4 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 24 Mar 2021 09:10:06 +0100 Subject: [PATCH 13/33] Role: Add param `$cascadeUpwards` also to public method `grant()` --- library/Icinga/Authentication/Role.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/library/Icinga/Authentication/Role.php b/library/Icinga/Authentication/Role.php index 1fca29b9d..c409ba4db 100644 --- a/library/Icinga/Authentication/Role.php +++ b/library/Icinga/Authentication/Role.php @@ -253,19 +253,20 @@ class Role * * @param string $permission * @param bool $ignoreParent Only evaluate the role's own permissions + * @param bool $cascadeUpwards `false` if `foo/bar/*` and `foo/bar/raboof` should not match `foo/*` * * @return bool */ - public function grants($permission, $ignoreParent = false) + public function grants($permission, $ignoreParent = false, $cascadeUpwards = true) { foreach ($this->permissions as $grantedPermission) { - if ($this->match($grantedPermission, $permission)) { + if ($this->match($grantedPermission, $permission, $cascadeUpwards)) { return true; } } if (! $ignoreParent && $this->getParent() !== null) { - return $this->getParent()->grants($permission); + return $this->getParent()->grants($permission, false, $cascadeUpwards); } return false; From ce1fed1b1d21a9159dbd2c630782978ad439bffb Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 25 Mar 2021 17:45:35 +0100 Subject: [PATCH 14/33] css: Use specific colors to represent grants, refusals and restrictions --- public/css/icinga/base.less | 5 +++++ public/css/icinga/widgets.less | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/public/css/icinga/base.less b/public/css/icinga/base.less index 4f31ba7e7..83df53318 100644 --- a/public/css/icinga/base.less +++ b/public/css/icinga/base.less @@ -67,6 +67,11 @@ @menu-flyout-bg-color: @body-bg-color; @menu-flyout-color: @text-color; +// Other colors +@color-granted: #59cd59; +@color-refused: #ee7373; +@color-restricted: #dede7d; + // Font families @font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; @font-family-fixed: "Liberation Mono", "Lucida Console", Courier, monospace; diff --git a/public/css/icinga/widgets.less b/public/css/icinga/widgets.less index d8410178b..189e01950 100644 --- a/public/css/icinga/widgets.less +++ b/public/css/icinga/widgets.less @@ -255,11 +255,11 @@ form.role-form { text-align: center; &.icon-ok { - color: @color-ok; + color: @color-granted; } &.icon-cancel { - color: @color-critical; + color: @color-refused; } } } From f31b1569aa384b3889aab65aeae21ea4ca7daab6 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 25 Mar 2021 17:46:06 +0100 Subject: [PATCH 15/33] css: Add new mixin `.user-select()` --- public/css/icinga/mixins.less | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/public/css/icinga/mixins.less b/public/css/icinga/mixins.less index 6cd08df04..ea9ae54be 100644 --- a/public/css/icinga/mixins.less +++ b/public/css/icinga/mixins.less @@ -67,6 +67,13 @@ transform: @transform; } +.user-select(@user-select) { + -webkit-user-select: @user-select; + -moz-user-select: @user-select; + -ms-user-select: @user-select; + user-select: @user-select; +} + .rounded-corners(@border-radius: 0.4em) { border-radius: @border-radius; From 074f08db4e395a50fa20f40d56e95c8f40d10c29 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 25 Mar 2021 17:47:30 +0100 Subject: [PATCH 16/33] Introduce view `Icinga\Web\View\PrivilegeAudit` --- library/Icinga/Web/StyleSheet.php | 3 +- library/Icinga/Web/View/PrivilegeAudit.php | 540 +++++++++++++++++++++ public/css/icinga/audit.less | 318 ++++++++++++ 3 files changed, 860 insertions(+), 1 deletion(-) create mode 100644 library/Icinga/Web/View/PrivilegeAudit.php create mode 100644 public/css/icinga/audit.less diff --git a/library/Icinga/Web/StyleSheet.php b/library/Icinga/Web/StyleSheet.php index 7bcd1f9ed..dfa002c2f 100644 --- a/library/Icinga/Web/StyleSheet.php +++ b/library/Icinga/Web/StyleSheet.php @@ -51,7 +51,8 @@ class StyleSheet 'css/icinga/compat.less', 'css/icinga/print.less', 'css/icinga/responsive.less', - 'css/icinga/modal.less' + 'css/icinga/modal.less', + 'css/icinga/audit.less' ]; /** diff --git a/library/Icinga/Web/View/PrivilegeAudit.php b/library/Icinga/Web/View/PrivilegeAudit.php new file mode 100644 index 000000000..0e63fc9d1 --- /dev/null +++ b/library/Icinga/Web/View/PrivilegeAudit.php @@ -0,0 +1,540 @@ + 'privilege-audit']; + + /** @var Role[] */ + protected $roles; + + public function __construct(array $roles) + { + $this->roles = $roles; + $this->setBaseTarget('_next'); + } + + protected function auditPermission($permission) + { + $grantedBy = []; + $refusedBy = []; + foreach ($this->roles as $role) { + if ($permission === self::UNRESTRICTED_PERMISSION) { + if ($role->isUnrestricted()) { + $grantedBy[] = $role->getName(); + } + } elseif ($role->denies($permission)) { + $refusedBy[] = $role->getName(); + } elseif ($role->grants($permission, false, false)) { + $grantedBy[] = $role->getName(); + } + } + + $header = new HtmlElement('div'); + if (! empty($refusedBy)) { + $header->add([ + new Icon('times-circle', ['class' => 'refused']), + count($refusedBy) > 2 + ? sprintf( + tp( + 'Refused by %s and %s as well as one other', + 'Refused by %s and %s as well as %d others', + count($refusedBy) - 2 + ), + $refusedBy[0], + $refusedBy[1], + count($refusedBy) - 2 + ) + : sprintf( + tp('Refused by %s', 'Refused by %s and %s', count($refusedBy)), + ...$refusedBy + ) + ]); + } elseif (! empty($grantedBy)) { + $header->add([ + new Icon('check-circle', ['class' => 'granted']), + count($grantedBy) > 2 + ? sprintf( + tp( + 'Granted by %s and %s as well as one other', + 'Granted by %s and %s as well as %d others', + count($grantedBy) - 2 + ), + $grantedBy[0], + $grantedBy[1], + count($grantedBy) - 2 + ) + : sprintf( + tp('Granted by %s', 'Granted by %s and %s', count($grantedBy)), + ...$grantedBy + ) + ]); + } else { + $header->add([new Icon('minus-circle'), t('Not granted or refused by any role')]); + } + + $vClass = null; + $rolePaths = []; + foreach (array_reverse($this->roles) as $role) { + if (! in_array($role->getName(), $refusedBy, true) && ! in_array($role->getName(), $grantedBy, true)) { + continue; + } + + /** @var Role[] $rolesReversed */ + $rolesReversed = []; + + do { + array_unshift($rolesReversed, $role); + } while (($role = $role->getParent()) !== null); + + $path = new HtmlElement('ol'); + + $class = null; + $initiator = null; + $setInitiator = false; + foreach ($rolesReversed as $role) { + $granted = false; + $refused = false; + $icon = new Icon('minus-circle'); + if ($permission === self::UNRESTRICTED_PERMISSION) { + if ($role->isUnrestricted()) { + $granted = true; + $icon = new Icon('check-circle', ['class' => 'granted']); + } + } elseif ($role->denies($permission, true)) { + $refused = true; + $icon = new Icon('times-circle', ['class' => 'refused']); + } elseif ($role->grants($permission, true, false)) { + $granted = true; + $icon = new Icon('check-circle', ['class' => 'granted']); + } + + $connector = null; + if ($role->getParent() !== null) { + $connector = new HtmlElement('li', ['class' => ['connector', $class]]); + if ($setInitiator) { + $setInitiator = false; + $initiator = $connector; + } + + $path->prepend($connector); + } + + $path->prepend(new HtmlElement('li', [ + 'class' => ['role', $class], + 'title' => $role->getName() + ], new Link([$icon, $role->getName()], Url::fromPath('role/edit', ['role' => $role->getName()])))); + + if ($refused) { + $setInitiator = $class !== 'refused'; + $class = 'refused'; + } elseif ($granted) { + $setInitiator = $class === null; + $class = $class ?: 'granted'; + } + } + + if ($initiator !== null) { + $initiator->getAttributes()->add('class', 'initiator'); + } + + if ($vClass === null || $vClass === 'granted') { + $vClass = $class; + } + + array_unshift($rolePaths, $path->prepend([ + empty($rolePaths) ? null : new HtmlElement('li', ['class' => ['vertical-line', $vClass]]), + new HtmlElement('li', ['class' => [ + 'connector', + $class, + $initiator === null ? 'initiator' : null + ]]) + ])); + } + + return [ + empty($refusedBy) ? (empty($grantedBy) ? null : true) : false, + new HtmlElement('div', [ + 'class' => [empty($rolePaths) ? null : 'collapsible', 'inheritance-paths'], + 'data-toggle-element' => '.collapsible-control', + 'data-no-persistence' => true, + 'data-visible-height' => 0 + ], [ + empty($rolePaths) ? $header : $header->addAttributes(['class' => 'collapsible-control']), + $rolePaths + ]) + ]; + } + + protected function auditRestriction($restriction) + { + $restrictedBy = []; + $restrictions = []; + foreach ($this->roles as $role) { + if ($role->isUnrestricted()) { + $restrictedBy = []; + $restrictions = []; + break; + } + + if (($roleRestriction = $role->getRestrictions($restriction)) !== null) { + $restrictedBy[] = $role->getName(); + $restrictions[] = $roleRestriction; + } + } + + $header = new HtmlElement('div'); + if (! empty($restrictedBy)) { + $header->add([ + new Icon('filter', ['class' => 'restricted']), + count($restrictedBy) > 2 + ? sprintf( + tp( + 'Restricted by %s and %s as well as one other', + 'Restricted by %s and %s as well as %d others', + count($restrictedBy) - 2 + ), + $restrictedBy[0], + $restrictedBy[1], + count($restrictedBy) - 2 + ) + : sprintf( + tp('Restricted by %s', 'Restricted by %s and %s', count($restrictedBy)), + ...$restrictedBy + ) + ]); + } else { + $header->add([new Icon('filter'), t('Not restricted by any role')]); + } + + $roles = []; + if (! empty($restrictions) && count($restrictions) > 1) { + list($combinedRestrictions, $combinedLinks) = $this->createRestrictionLinks($restriction, $restrictions); + $roles[] = new HtmlElement('li', null, [ + new HtmlElement('div', ['class' => 'flex-overflow'], [ + new HtmlElement('span', [ + 'class' => 'role', + 'title' => t('All roles combined') + ], join(' | ', $restrictedBy)), + new HtmlElement('code', ['class' => 'restriction'], $combinedRestrictions) + ]), + $combinedLinks ? new HtmlElement('div', ['class' => 'previews'], [ + new HtmlElement('em', null, t('Previews:')), + $combinedLinks + ]) : null + ]); + } + + foreach ($this->roles as $role) { + if (! in_array($role->getName(), $restrictedBy, true)) { + continue; + } + + list($roleRestriction, $restrictionLinks) = $this->createRestrictionLinks( + $restriction, + [$role->getRestrictions($restriction)] + ); + + $roles[] = new HtmlElement('li', null, [ + new HtmlElement('div', ['class' => 'flex-overflow'], [ + new Link($role->getName(), Url::fromPath('role/edit', ['role' => $role->getName()]), [ + 'class' => 'role', + 'title' => $role->getName() + ]), + new HtmlElement('code', ['class' => 'restriction'], $roleRestriction) + ]), + $restrictionLinks ? new HtmlElement('div', ['class' => 'previews'], [ + new HtmlElement('em', null, t('Previews:')), + $restrictionLinks + ]) : null + ]); + } + + return [ + ! empty($restrictedBy), + new HtmlElement('div', [ + 'class' => [empty($roles) ? null : 'collapsible', 'restrictions'], + 'data-toggle-element' => '.collapsible-control', + 'data-no-persistence' => true, + 'data-visible-height' => 0 + ], [ + empty($roles) ? $header : $header->addAttributes(['class' => 'collapsible-control']), + new HtmlElement('ul', null, $roles) + ]) + ]; + } + + protected function assemble() + { + list($permissions, $restrictions) = RoleForm::collectProvidedPrivileges(); + list($wildcardState, $wildcardAudit) = $this->auditPermission('*'); + list($unrestrictedState, $unrestrictedAudit) = $this->auditPermission(self::UNRESTRICTED_PERMISSION); + + $this->add(new HtmlElement('li', [ + 'class' => 'collapsible', + 'data-toggle-element' => 'h3', + 'data-visible-height' => 0 + ], [ + new HtmlElement('h3', null, [ + new HtmlElement('span', null, t('Administrative Privileges')), + new HtmlElement('span', ['class' => 'audit-preview'], [ + $wildcardState || $unrestrictedState + ? new Icon('check-circle', ['class' => 'granted']) + : null + ]) + ]), + new HtmlElement('ol', ['class' => 'privilege-list'], [ + new HtmlElement('li', null, [ + new HtmlElement('p', ['class' => 'privilege-label'], t('Administrative Access')), + new HtmlElement('div', ['class' => 'spacer']), + $wildcardAudit + ]), + new HtmlElement('li', null, [ + new HtmlElement('p', ['class' => 'privilege-label'], t('Unrestricted Access')), + new HtmlElement('div', ['class' => 'spacer']), + $unrestrictedAudit + ]) + ]) + ])); + + $privilegeSources = array_unique(array_merge(array_keys($permissions), array_keys($restrictions))); + foreach ($privilegeSources as $source) { + $anythingGranted = false; + $anythingRefused = false; + $anythingRestricted = false; + + $permissionList = new HtmlElement('ol', ['class' => 'privilege-list']); + foreach (isset($permissions[$source]) ? $permissions[$source] : [] as $permission => $metaData) { + list($permissionState, $permissionAudit) = $this->auditPermission($permission); + if ($permissionState !== null) { + if ($permissionState) { + $anythingGranted = true; + } else { + $anythingRefused = true; + } + } + + $permissionList->add(new HtmlElement('li', null, [ + new HtmlElement( + 'p', + ['class' => 'privilege-label'], + isset($metaData['label']) + ? $metaData['label'] + : array_map(function ($segment) { + return $segment[0] === '/' ? [ + // Adds a zero-width char after each slash to help browsers break onto newlines + new HtmlString('/​'), + new HtmlElement('span', ['class' => 'no-wrap'], substr($segment, 1)) + ] : new HtmlElement('em', null, $segment); + }, preg_split( + '~(/[^/]+)~', + $permission, + -1, + PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY + )) + ), + new HtmlElement('div', ['class' => 'spacer']), + $permissionAudit + ])); + } + + $restrictionList = new HtmlElement('ol', ['class' => 'privilege-list']); + foreach (isset($restrictions[$source]) ? $restrictions[$source] : [] as $restriction => $metaData) { + list($restrictionState, $restrictionAudit) = $this->auditRestriction($restriction); + if ($restrictionState) { + $anythingRestricted = true; + } + + $restrictionList->add(new HtmlElement('li', null, [ + new HtmlElement( + 'p', + ['class' => 'privilege-label'], + isset($metaData['label']) + ? $metaData['label'] + : array_map(function ($segment) { + return $segment[0] === '/' ? [ + // Adds a zero-width char after each slash to help browsers break onto newlines + new HtmlString('/​'), + new HtmlElement('span', ['class' => 'no-wrap'], substr($segment, 1)) + ] : new HtmlElement('em', null, $segment); + }, preg_split( + '~(/[^/]+)~', + $restriction, + -1, + PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY + )) + ), + new HtmlElement('div', ['class' => 'spacer']), + $restrictionAudit + ])); + } + + if ($source === 'application') { + $label = 'Icinga Web 2'; + } else { + $label = [$source, ' ', new HtmlElement('em', null, t('Module'))]; + } + + $this->add(new HtmlElement('li', [ + 'class' => 'collapsible', + 'data-toggle-element' => 'h3', + 'data-visible-height' => 0 + ], [ + new HtmlElement('h3', null, [ + new HtmlElement('span', null, $label), + new HtmlElement('span', ['class' => 'audit-preview'], [ + $anythingGranted ? new Icon('check-circle', ['class' => 'granted']) : null, + $anythingRefused ? new Icon('times-circle', ['class' => 'refused']) : null, + $anythingRestricted ? new Icon('filter', ['class' => 'restricted']) : null + ]) + ]), + $permissionList->isEmpty() ? null : [ + new HtmlElement('h4', null, t('Permissions')), + $permissionList + ], + $restrictionList->isEmpty() ? null : [ + new HtmlElement('h4', null, t('Restrictions')), + $restrictionList + ] + ])); + } + } + + private function createRestrictionLinks($restrictionName, array $restrictions) + { + // TODO: Remove this hardcoded mess. Do this based on the restriction's meta data + switch ($restrictionName) { + case 'icingadb/filter/objects': + $filterString = join('|', $restrictions); + $list = new HtmlElement('ul', ['class' => 'links'], [ + new HtmlElement('li', null, new Link( + 'icingadb/hosts', + Url::fromPath('icingadb/hosts')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'icingadb/services', + Url::fromPath('icingadb/services')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'icingadb/hostgroups', + Url::fromPath('icingadb/hostgroups')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'icingadb/servicegroups', + Url::fromPath('icingadb/servicegroups')->setQueryString($filterString) + )) + ]); + + break; + case 'icingadb/filter/hosts': + $filterString = join('|', $restrictions); + $list = new HtmlElement('ul', ['class' => 'links'], [ + new HtmlElement('li', null, new Link( + 'icingadb/hosts', + Url::fromPath('icingadb/hosts')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'icingadb/services', + Url::fromPath('icingadb/services')->setQueryString($filterString) + )) + ]); + + break; + case 'icingadb/filter/services': + $filterString = join('|', $restrictions); + $list = new HtmlElement('ul', ['class' => 'links'], [ + new HtmlElement('li', null, new Link( + 'icingadb/services', + Url::fromPath('icingadb/services')->setQueryString($filterString) + )) + ]); + + break; + case 'monitoring/filter/objects': + $filterString = join('|', $restrictions); + $list = new HtmlElement('ul', ['class' => 'links'], [ + new HtmlElement('li', null, new Link( + 'monitoring/list/hosts', + Url::fromPath('monitoring/list/hosts')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'monitoring/list/services', + Url::fromPath('monitoring/list/services')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'monitoring/list/hostgroups', + Url::fromPath('monitoring/list/hostgroups')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'monitoring/list/servicegroups', + Url::fromPath('monitoring/list/servicegroups')->setQueryString($filterString) + )) + ]); + + break; + case 'application/share/users': + $filter = Filter::any(); + foreach ($restrictions as $roleRestriction) { + $userNames = StringHelper::trimSplit($roleRestriction); + foreach ($userNames as $userName) { + $filter->add(Filter::equal('user_name', $userName)); + } + } + + $filterString = QueryString::render($filter); + $list = new HtmlElement('ul', ['class' => 'links'], [ + new HtmlElement('li', null, new Link( + 'user/list', + Url::fromPath('user/list')->setQueryString($filterString) + )) + ]); + + break; + case 'application/share/groups': + $filter = Filter::any(); + foreach ($restrictions as $roleRestriction) { + $groupNames = StringHelper::trimSplit($roleRestriction); + foreach ($groupNames as $groupName) { + $filter->add(Filter::equal('group_name', $groupName)); + } + } + + $filterString = QueryString::render($filter); + $list = new HtmlElement('ul', ['class' => 'links'], [ + new HtmlElement('li', null, new Link( + 'group/list', + Url::fromPath('group/list')->setQueryString($filterString) + )) + ]); + + break; + default: + $filterString = join(', ', $restrictions); + $list = null; + } + + return [$filterString, $list]; + } +} diff --git a/public/css/icinga/audit.less b/public/css/icinga/audit.less new file mode 100644 index 000000000..d65f89ca5 --- /dev/null +++ b/public/css/icinga/audit.less @@ -0,0 +1,318 @@ +// Style + +.privilege-audit { + &, ul, ol { + list-style-type: none; + } + + h3 { + border-bottom: 1px solid @gray-light; + } + + h3 em, + .previews em, + .privilege-label em { + color: @text-color-light; + } + h3 em { + font-weight: normal; + } + .privilege-label em { + font-style: normal; + } + + .icon { + color: @gray-light; + + &.granted { + color: @color-granted; + } + + &.refused { + color: @color-refused; + } + + &.restricted { + color: @color-restricted; + } + } + + .privilege-list > li { + .spacer { + opacity: 0; + .transition(opacity .5s ease-out); + } + + &:hover .spacer { + .transition(opacity .25s .25s ease-in); + border: 0 dashed @gray-light; + border-top-width: .2em; + opacity: 1; + } + } + + .vertical-line { + border: 0 solid; + border-left-width: 2px; + + &.granted { + border-color: @color-granted; + } + + &.refused { + border-color: @color-refused; + } + } + + .connector { + border: 0 solid @gray-lighter; + border-bottom-width: 2px; + + &.granted { + border-color: @color-granted; + } + + &.refused { + border-color: @color-refused; + } + + &:first-child { + border-width: 0 0 2px 2px; + border-bottom-left-radius: .5em; + } + } + + .role { + .rounded-corners(1em); + border: 2px solid @gray-lighter; + + &.granted { + border: 2px solid @color-granted; + } + + &.refused { + border: 2px solid @color-refused; + } + } + + .restriction { + font-family: @font-family-fixed; + background-color: @gray-lighter; + } +} + +// Layout + +.privilege-audit { + &, ul, ol { + margin: 0; + padding: 0; + } + + > li:not(.collapsed) { + margin-bottom: 2em; + } + + .flex-overflow, + .privilege-list > li, + .inheritance-paths > ol { + display: flex; + } + + .privilege-list > li { + margin-top: 1em; + + > :last-child { + // This aids the usage of text-overflow:ellipsis in any of the children. + // It seems that to get this working while none of the children has a + // defined width, any flex item on the way up to the clipped container + // also must have a overflow value of "hidden". + // https://codepen.io/unthinkingly/pen/XMwJLG + overflow: hidden; + } + } + + h3 { + display: flex; + + > :first-child { + flex: 3 1 auto; + min-width: 20em; + max-width: 40em / 1.167em; // privilege label width + spacer width / h3 font-size + } + + .audit-preview { + flex: 1 1 auto; + + .icon:before { + width: 1.25em; + font-size: 1.25em / 1.167em; // privilege state icon font-size / h3 font-size + } + } + + em { + font-size: .857em; + } + } + + h4, + .privilege-label { + flex-shrink: 0; + width: 20em; + margin: 0; + text-align: right; + } + + ol + h4 { + margin-top: 1em; + } + + .spacer { + flex: 20 1 auto; + min-width: 10em; // TODO: Mobile? + max-width: 18.8em; // 20em - (margin-left + margin-right) + margin: .6em; + } + + .inheritance-paths, + .restrictions { + flex: 1 1 auto; + + > div { + line-height: 1; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + > .icon:before { + width: 1.25em; + font-size: 1.25em; + } + } + } + + .vertical-line { + margin-left: ~"calc(.75em - 1px)"; + } + + .connector { + flex: 1 1 auto; + width: 2em; + max-width: 2em; + min-width: 1em; + margin-bottom: ~"calc(1em - 1px)"; + + &:first-child { + margin-left: ~"calc(.75em - 1px)"; + } + + &.initiator { + z-index: 1; + margin-right: ~"calc(-.25em - 2px)"; + } + } + + .vertical-line + .connector { + min-width: ~"calc(.75em - 2px)"; + width: ~"calc(.75em - 2px)"; + flex-grow: 0; + + &.initiator { + width: ~"calc(1em - 1px)"; + } + } + .connector:first-child { + min-width: .75em; + width: .75em; + flex-grow: 0; + + &.initiator { + width: 1em; + } + } + + .role { + padding: .25em .5em .25em .5em; + line-height: 1; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + .icon:before { + font-size: 1.25em; + } + } + .inheritance-paths .role { + min-width: 4em; + margin-top: .5em; + padding-left: .25em; + } + .restrictions .role { + display: inline-block; + } + + .previews { + display: flex; + margin-top: .25em; + + em { + // explicit margin + ((header icon width + its margin right) * 125% font-size) + margin: 0 1em 0 1em + ((1.25em + .2em) * 1.25em); + } + } + + .links li:not(:last-child):after { + content: ","; + } + + .restrictions > ul > li { + margin-top: .5em; + + .role { + margin-left: 1.25em + .2em * 1.25em; // (header icon width + its margin right) * 125% font-size + margin-right: 1em; + } + } + + .restriction { + font-size: .8em; + padding: .335em / .8em; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + .user-select(all); + } +} + +// Integrations + +.privilege-audit .collapsible { + .collapsible-control { + cursor: pointer; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + } + + .collapsible-control:after { + content: "\f103"; + display: inline-block; + font-family: 'ifont'; + font-weight: normal; + padding: 0 .25em; + margin-right: .25em; + width: 1em; + opacity: .6; + float: right; + } + + &.collapsed .collapsible-control:after { + content: "\e87a"; + } +} From be227fd61dd98502bb7590e996112702c40c5c90 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 25 Mar 2021 17:48:18 +0100 Subject: [PATCH 17/33] roles/audit: Utilize view `Icinga\Web\View\PrivilegeAudit` --- application/controllers/RoleController.php | 72 +++++++++++++++++++++- public/css/icinga/audit.less | 29 +++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/application/controllers/RoleController.php b/application/controllers/RoleController.php index c0df218ab..f3f6591f6 100644 --- a/application/controllers/RoleController.php +++ b/application/controllers/RoleController.php @@ -5,15 +5,22 @@ namespace Icinga\Controllers; use Exception; use GuzzleHttp\Psr7\ServerRequest; +use Icinga\Authentication\AdmissionLoader; +use Icinga\Authentication\Auth; use Icinga\Authentication\RolesConfig; use Icinga\Data\Selectable; use Icinga\Exception\NotFoundError; use Icinga\Forms\Security\RoleForm; +use Icinga\Module\Icingadb\Widget\EmptyState; use Icinga\Repository\Repository; use Icinga\Security\SecurityException; +use Icinga\User; use Icinga\Web\Controller\AuthBackendController; +use Icinga\Web\View\PrivilegeAudit; use Icinga\Web\Widget\SingleValueSearchControl; +use ipl\Html\Html; use ipl\Web\Url; +use ipl\Web\Widget\Link; /** * Manage user permissions and restrictions based on roles @@ -137,7 +144,9 @@ class RoleController extends AuthBackendController { $this->assertPermission('config/access-control/roles'); $this->createListTabs()->activate('role/audit'); + $this->view->title = t('Audit'); + $roleName = $this->params->get('role'); $type = $this->params->has('group') ? 'group' : 'user'; $name = $this->params->get($type); @@ -167,7 +176,68 @@ class RoleController extends AuthBackendController $this->redirectNow(Url::fromPath('role/audit', $params)); })->handleRequest(ServerRequest::fromGlobals()); - $this->content->add($form); + $this->addControl($form); + + if (! $name) { + $this->addContent(new EmptyState(t('No user or group selected.'))); + return; + } + + if ($type === 'user') { + $header = Html::tag('h2', sprintf(t('Privilege Audit for User "%s"'), $name)); + + $user = new User($name); + $user->setAdditional('backend_name', $backend); + Auth::getInstance()->setupUser($user); + } else { + $header = Html::tag('h2', sprintf(t('Privilege Audit for Group "%s"'), $name)); + + $user = new User((string) time()); + $user->setGroups([$name]); + (new AdmissionLoader())->applyRoles($user); + } + + $chosenRole = null; + $assignedRoles = array_filter($user->getRoles(), function ($role) use ($user, &$chosenRole, $roleName) { + if (! in_array($role->getName(), $user->getAdditional('assigned_roles'), true)) { + return false; + } + + if ($role->getName() === $roleName) { + $chosenRole = $role; + } + + return true; + }); + + $this->addControl(Html::tag( + 'ul', + ['class' => 'privilege-audit-role-control'], + [ + Html::tag('li', $roleName ? null : ['class' => 'active'], new Link( + t('All roles'), + Url::fromRequest()->without('role'), + ['class' => 'button-link', 'title' => t('Show privileges of all roles')] + )), + array_map(function ($role) use ($roleName) { + return Html::tag( + 'li', + $role->getName() === $roleName ? ['class' => 'active'] : null, + new Link( + $role->getName(), + Url::fromRequest()->setParam('role', $role->getName()), + [ + 'class' => 'button-link', + 'title' => sprintf(t('Only show privileges of role %s'), $role->getName()) + ] + ) + ); + }, $assignedRoles) + ] + )); + + $this->addControl($header); + $this->addContent(new PrivilegeAudit($chosenRole !== null ? [$chosenRole] : $assignedRoles)); } public function suggestRoleMemberAction() diff --git a/public/css/icinga/audit.less b/public/css/icinga/audit.less index d65f89ca5..fae337cec 100644 --- a/public/css/icinga/audit.less +++ b/public/css/icinga/audit.less @@ -1,5 +1,18 @@ // Style +.privilege-audit-role-control { + list-style-type: none; + + li { + .rounded-corners(3px); + border: 1px solid @low-sat-blue; + + &.active { + border-color: @icinga-blue; + } + } +} + .privilege-audit { &, ul, ol { list-style-type: none; @@ -103,6 +116,22 @@ // Layout +.privilege-audit-role-control { + display: inline-flex; + flex-wrap: wrap; + + margin: 0 0 0 1em; + padding: 0; + + li { + margin-top: @vertical-padding; + + &:not(:first-child) { + margin-left: .5em; + } + } +} + .privilege-audit { &, ul, ol { margin: 0; From 68f101b0158716c286fc73ed8d4e11993736bc2b Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 26 Mar 2021 14:25:48 +0100 Subject: [PATCH 18/33] RoleForm: Don't let privilege labels break on dashes --- application/forms/Security/RoleForm.php | 56 ++++++++++++++++--------- public/css/icinga/main.less | 4 ++ 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/application/forms/Security/RoleForm.php b/application/forms/Security/RoleForm.php index 4afd48b8c..ff44e820f 100644 --- a/application/forms/Security/RoleForm.php +++ b/application/forms/Security/RoleForm.php @@ -171,16 +171,24 @@ class RoleForm extends RepositoryForm 'autosubmit' => isset($spec['isFullPerm']), 'disabled' => $hasFullPerm || $hasAdminPerm ?: null, 'value' => $hasFullPerm || $hasAdminPerm, - 'label' => preg_replace( - // Adds a zero-width char after each slash to help browsers break onto newlines - '~(?$1$2', - $name - ) - ), + 'label' => isset($spec['label']) + ? $spec['label'] + : join('', iterator_to_array(call_user_func(function ($segments) { + foreach ($segments as $segment) { + if ($segment[0] === '/') { + // Adds a zero-width char after each slash to help browsers break onto newlines + yield '/​'; + yield '' . substr($segment, 1) . ''; + } else { + yield '' . $segment . ''; + } + } + }, preg_split( + '~(/[^/]+)~', + $name, + -1, + PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY + )))), 'description' => isset($spec['description']) ? $spec['description'] : $name, 'decorators' => array_merge( array_slice(self::$defaultElementDecorators, 0, 3), @@ -220,16 +228,24 @@ class RoleForm extends RepositoryForm 'text', $elementName, [ - 'label' => preg_replace( - // Adds a zero-width char after each slash to help browsers break onto newlines - '~(?$1$2', - $name - ) - ), + 'label' => isset($spec['label']) + ? $spec['label'] + : join('', iterator_to_array(call_user_func(function ($segments) { + foreach ($segments as $segment) { + if ($segment[0] === '/') { + // Add zero-width char after each slash to help browsers break onto newlines + yield '/​'; + yield '' . substr($segment, 1) . ''; + } else { + yield '' . $segment . ''; + } + } + }, preg_split( + '~(/[^/]+)~', + $name, + -1, + PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY + )))), 'description' => $spec['description'], 'style' => $isUnrestricted ? 'text-decoration:line-through;' : '', 'readonly' => $isUnrestricted ?: null diff --git a/public/css/icinga/main.less b/public/css/icinga/main.less index 15bee5cda..0fe9ed634 100644 --- a/public/css/icinga/main.less +++ b/public/css/icinga/main.less @@ -45,6 +45,10 @@ } } +.no-wrap { + white-space: nowrap; +} + .pull-right { float: right; } From e288ccd713547c1f448bb57b8bc92ae6dce5bbbb Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 26 Mar 2021 14:27:38 +0100 Subject: [PATCH 19/33] css: Don't override padding of all inputs in controls inputs are expected to have their own padding and if that doesn't suit everywhere, they have to be adjusted. Overriding padding and such stuff this generally is bad. This also solves the problem that the filter editor search field is missing its specific padding, yay. -.- --- public/css/icinga/controls.less | 1 - 1 file changed, 1 deletion(-) diff --git a/public/css/icinga/controls.less b/public/css/icinga/controls.less index de3f65182..0b53ccc8a 100644 --- a/public/css/icinga/controls.less +++ b/public/css/icinga/controls.less @@ -193,7 +193,6 @@ input.search { input, select { - padding: .5em; max-width: 16em; } From 9db50eb75bf5b69a886cb44a8c4ce9dadd348e24 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 26 Mar 2021 14:31:08 +0100 Subject: [PATCH 20/33] css: Enhance layout of inline forms --- public/css/icinga/forms.less | 15 +++++++++++++++ public/css/icinga/main.less | 2 +- public/css/icinga/mixins.less | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/public/css/icinga/forms.less b/public/css/icinga/forms.less index 7ada043ff..ba0eff81e 100644 --- a/public/css/icinga/forms.less +++ b/public/css/icinga/forms.less @@ -49,6 +49,12 @@ form.icinga-form { form.inline { display: inline-block; + + fieldset { + display: inline-block; + vertical-align: top; + border: none; + } } // Minimal form layout @@ -300,6 +306,15 @@ form.icinga-form .form-controls { } form.inline { + :not([type="hidden"]) { + & ~ button:not([type]), + & ~ button[type="submit"], + & ~ input[type="submit"], + & ~ input[type="submit"].btn-confirm { + margin-left: @horizontal-padding; + } + } + button.noscript-apply { margin-left: .5em; padding: .1em; diff --git a/public/css/icinga/main.less b/public/css/icinga/main.less index 0fe9ed634..005d4bb4f 100644 --- a/public/css/icinga/main.less +++ b/public/css/icinga/main.less @@ -390,4 +390,4 @@ a:hover > .icon-cancel { font-weight: bold; } } -} \ No newline at end of file +} diff --git a/public/css/icinga/mixins.less b/public/css/icinga/mixins.less index ea9ae54be..682482c2d 100644 --- a/public/css/icinga/mixins.less +++ b/public/css/icinga/mixins.less @@ -15,7 +15,7 @@ cursor: pointer; line-height: normal; outline: none; - padding: @vertical-padding @horizontal-padding; + padding: ~"calc(@{vertical-padding} - 2px)" @horizontal-padding; @duration: 0.2s; // The trailing semicolon is needed to be able to pass this as a css list From 698e7bcfa44c4501bed1a8d8fd434986e61c42b8 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 26 Mar 2021 16:11:31 +0100 Subject: [PATCH 21/33] SingleValueSearchControl: Show magnifier icon in the text input --- library/Icinga/Web/Widget/SingleValueSearchControl.php | 1 + 1 file changed, 1 insertion(+) diff --git a/library/Icinga/Web/Widget/SingleValueSearchControl.php b/library/Icinga/Web/Widget/SingleValueSearchControl.php index 8df26a9af..414b0ef52 100644 --- a/library/Icinga/Web/Widget/SingleValueSearchControl.php +++ b/library/Icinga/Web/Widget/SingleValueSearchControl.php @@ -112,6 +112,7 @@ class SingleValueSearchControl extends Form 'required' => true, 'minlength' => 1, 'autocomplete' => 'off', + 'class' => 'search', 'data-enrichment-type' => 'completion', 'data-term-suggestions' => '#' . $suggestionsId, 'data-suggest-url' => $this->suggestionUrl, From bab10899a204823e219b0b130e5aba7476e6a223 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 26 Mar 2021 16:28:55 +0100 Subject: [PATCH 22/33] role/audit: Fix layout on mobile devices Not perfect, but enough for now. --- public/css/icinga/audit.less | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/public/css/icinga/audit.less b/public/css/icinga/audit.less index fae337cec..422e9c800 100644 --- a/public/css/icinga/audit.less +++ b/public/css/icinga/audit.less @@ -316,6 +316,25 @@ } } +#layout.minimal-layout, +#layout.poor-layout { + .privilege-audit { + h3 > :first-child { + flex-grow: 99; + } + + h4, + .privilege-label { + width: 12em; + } + + .spacer { + flex: 0; + min-width: 0; + } + } +} + // Integrations .privilege-audit .collapsible { From fa2c3c8999a2c9251bb1431bd3982cabe08db38f Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 26 Mar 2021 16:56:19 +0100 Subject: [PATCH 23/33] role/audit: Give the privilege audit an explicit (non-protected) id --- application/controllers/RoleController.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/application/controllers/RoleController.php b/application/controllers/RoleController.php index f3f6591f6..e4f26e68d 100644 --- a/application/controllers/RoleController.php +++ b/application/controllers/RoleController.php @@ -237,7 +237,10 @@ class RoleController extends AuthBackendController )); $this->addControl($header); - $this->addContent(new PrivilegeAudit($chosenRole !== null ? [$chosenRole] : $assignedRoles)); + $this->addContent( + (new PrivilegeAudit($chosenRole !== null ? [$chosenRole] : $assignedRoles)) + ->addAttributes(['id' => 'role/audit']) + ); } public function suggestRoleMemberAction() From 65cfa9236c3abbf23ee0c189f1a525c803873ee5 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 30 Mar 2021 13:31:19 +0200 Subject: [PATCH 24/33] role/[add|edit|remove]: Set `__CLOSE__` as redirect target The form is also reachable through the audit view now. This results in the correct handling in either of both views. --- application/controllers/RoleController.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/application/controllers/RoleController.php b/application/controllers/RoleController.php index e4f26e68d..8f99603a5 100644 --- a/application/controllers/RoleController.php +++ b/application/controllers/RoleController.php @@ -84,7 +84,7 @@ class RoleController extends AuthBackendController $this->assertPermission('config/access-control/roles'); $role = new RoleForm(); - $role->setRedirectUrl('role/list'); + $role->setRedirectUrl('__CLOSE__'); $role->setRepository(new RolesConfig()); $role->setSubmitLabel($this->translate('Create Role')); $role->add()->handleRequest(); @@ -103,7 +103,7 @@ class RoleController extends AuthBackendController $name = $this->params->getRequired('role'); $role = new RoleForm(); - $role->setRedirectUrl('role/list'); + $role->setRedirectUrl('__CLOSE__'); $role->setRepository(new RolesConfig()); $role->setSubmitLabel($this->translate('Update Role')); $role->edit($name); @@ -126,7 +126,7 @@ class RoleController extends AuthBackendController $name = $this->params->getRequired('role'); $role = new RoleForm(); - $role->setRedirectUrl('role/list'); + $role->setRedirectUrl('__CLOSE__'); $role->setRepository(new RolesConfig()); $role->setSubmitLabel($this->translate('Remove Role')); $role->remove($name); From b5334a063e550642bf1b8c8ee434e738b455bc83 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 6 Apr 2021 16:09:45 +0200 Subject: [PATCH 25/33] PrivilegeAudit: Show missing restrictions if only parents restrict --- library/Icinga/Web/View/PrivilegeAudit.php | 32 ++++++++++++++-------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/library/Icinga/Web/View/PrivilegeAudit.php b/library/Icinga/Web/View/PrivilegeAudit.php index 0e63fc9d1..affa6cd3f 100644 --- a/library/Icinga/Web/View/PrivilegeAudit.php +++ b/library/Icinga/Web/View/PrivilegeAudit.php @@ -199,8 +199,8 @@ class PrivilegeAudit extends BaseHtmlElement break; } - if (($roleRestriction = $role->getRestrictions($restriction)) !== null) { - $restrictedBy[] = $role->getName(); + foreach ($this->collectRestrictions($role, $restriction) as $role => $roleRestriction) { + $restrictedBy[] = $role; $restrictions[] = $roleRestriction; } } @@ -216,13 +216,15 @@ class PrivilegeAudit extends BaseHtmlElement 'Restricted by %s and %s as well as %d others', count($restrictedBy) - 2 ), - $restrictedBy[0], - $restrictedBy[1], + $restrictedBy[0]->getName(), + $restrictedBy[1]->getName(), count($restrictedBy) - 2 ) : sprintf( tp('Restricted by %s', 'Restricted by %s and %s', count($restrictedBy)), - ...$restrictedBy + ...array_map(function ($role) { + return $role->getName(); + }, $restrictedBy) ) ]); } else { @@ -237,7 +239,9 @@ class PrivilegeAudit extends BaseHtmlElement new HtmlElement('span', [ 'class' => 'role', 'title' => t('All roles combined') - ], join(' | ', $restrictedBy)), + ], join(' | ', array_map(function ($role) { + return $role->getName(); + }, $restrictedBy))), new HtmlElement('code', ['class' => 'restriction'], $combinedRestrictions) ]), $combinedLinks ? new HtmlElement('div', ['class' => 'previews'], [ @@ -247,11 +251,7 @@ class PrivilegeAudit extends BaseHtmlElement ]); } - foreach ($this->roles as $role) { - if (! in_array($role->getName(), $restrictedBy, true)) { - continue; - } - + foreach ($restrictedBy as $role) { list($roleRestriction, $restrictionLinks) = $this->createRestrictionLinks( $restriction, [$role->getRestrictions($restriction)] @@ -422,6 +422,16 @@ class PrivilegeAudit extends BaseHtmlElement } } + private function collectRestrictions(Role $role, $restrictionName) + { + do { + $restriction = $role->getRestrictions($restrictionName); + if ($restriction) { + yield $role => $restriction; + } + } while (($role = $role->getParent()) !== null); + } + private function createRestrictionLinks($restrictionName, array $restrictions) { // TODO: Remove this hardcoded mess. Do this based on the restriction's meta data From 314ec5256cabac7b0aacff6c49b3d90d15dd091e Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 6 Apr 2021 16:10:21 +0200 Subject: [PATCH 26/33] PrivilegeAudit: Show missing initiators in inheritance paths --- library/Icinga/Web/View/PrivilegeAudit.php | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/library/Icinga/Web/View/PrivilegeAudit.php b/library/Icinga/Web/View/PrivilegeAudit.php index affa6cd3f..29988f8cd 100644 --- a/library/Icinga/Web/View/PrivilegeAudit.php +++ b/library/Icinga/Web/View/PrivilegeAudit.php @@ -112,7 +112,6 @@ class PrivilegeAudit extends BaseHtmlElement $path = new HtmlElement('ol'); $class = null; - $initiator = null; $setInitiator = false; foreach ($rolesReversed as $role) { $granted = false; @@ -136,7 +135,7 @@ class PrivilegeAudit extends BaseHtmlElement $connector = new HtmlElement('li', ['class' => ['connector', $class]]); if ($setInitiator) { $setInitiator = false; - $initiator = $connector; + $connector->getAttributes()->add('class', 'initiator'); } $path->prepend($connector); @@ -156,10 +155,6 @@ class PrivilegeAudit extends BaseHtmlElement } } - if ($initiator !== null) { - $initiator->getAttributes()->add('class', 'initiator'); - } - if ($vClass === null || $vClass === 'granted') { $vClass = $class; } @@ -169,7 +164,7 @@ class PrivilegeAudit extends BaseHtmlElement new HtmlElement('li', ['class' => [ 'connector', $class, - $initiator === null ? 'initiator' : null + $setInitiator ? 'initiator' : null ]]) ])); } From 5b970c79ad108c17af796700006c1130b4db9b56 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 7 Apr 2021 14:28:22 +0200 Subject: [PATCH 27/33] role/audit: Use `role-audit` as id instead of `role/audit` Since HTML5 nearly any char is allowed in ids, including the slash, but jQuery doesn't support the slash as part of css selectors... --- application/controllers/RoleController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/controllers/RoleController.php b/application/controllers/RoleController.php index 8f99603a5..b0f4c7ce3 100644 --- a/application/controllers/RoleController.php +++ b/application/controllers/RoleController.php @@ -239,7 +239,7 @@ class RoleController extends AuthBackendController $this->addControl($header); $this->addContent( (new PrivilegeAudit($chosenRole !== null ? [$chosenRole] : $assignedRoles)) - ->addAttributes(['id' => 'role/audit']) + ->addAttributes(['id' => 'role-audit']) ); } From d49962ac8296bf15411404966cd5809edd158241 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 7 Apr 2021 15:10:01 +0200 Subject: [PATCH 28/33] role/audit: Pre-populate backend name with the first one found Otherwise a user who doesn't use a suggestion will see an error. --- application/controllers/RoleController.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/application/controllers/RoleController.php b/application/controllers/RoleController.php index b0f4c7ce3..b1e5e54f5 100644 --- a/application/controllers/RoleController.php +++ b/application/controllers/RoleController.php @@ -151,8 +151,15 @@ class RoleController extends AuthBackendController $name = $this->params->get($type); $backend = null; - if ($name && $type === 'user') { - $backend = $this->params->getRequired('backend'); + if ($type === 'user') { + if ($name) { + $backend = $this->params->getRequired('backend'); + } else { + $backends = $this->loadUserBackends(); + if (! empty($backends)) { + $backend = array_shift($backends)->getName(); + } + } } $form = new SingleValueSearchControl(); From a6507daaaf8b068869f8a72d9f7f8b561cd1d0d0 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 7 Apr 2021 15:58:25 +0200 Subject: [PATCH 29/33] SingleValueSearchControl: Allow to pass html as labels --- library/Icinga/Web/Widget/SingleValueSearchControl.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/library/Icinga/Web/Widget/SingleValueSearchControl.php b/library/Icinga/Web/Widget/SingleValueSearchControl.php index 414b0ef52..548abcec5 100644 --- a/library/Icinga/Web/Widget/SingleValueSearchControl.php +++ b/library/Icinga/Web/Widget/SingleValueSearchControl.php @@ -156,8 +156,8 @@ class SingleValueSearchControl extends Form public static function createSuggestions(array $groups) { $ul = new HtmlElement('ul'); - foreach ($groups as $name => $entries) { - if (is_string($name)) { + foreach ($groups as list($name, $entries)) { + if ($name) { if ($entries === false) { $ul->add(new HtmlElement('li', ['class' => 'failure-message'], [ new HtmlElement('em', null, t('Can\'t search:')), @@ -169,7 +169,7 @@ class SingleValueSearchControl extends Form } } - foreach ($entries as $label => $metaData) { + foreach ($entries as list($label, $metaData)) { $attributes = [ 'value' => $label, 'type' => 'button', From 0d35a1774d6425b2787b799549243802d61f693c Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 7 Apr 2021 15:59:03 +0200 Subject: [PATCH 30/33] SingleValueSearchControl: Add failure message for empty results --- library/Icinga/Web/Widget/SingleValueSearchControl.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/library/Icinga/Web/Widget/SingleValueSearchControl.php b/library/Icinga/Web/Widget/SingleValueSearchControl.php index 548abcec5..99bbc5c4d 100644 --- a/library/Icinga/Web/Widget/SingleValueSearchControl.php +++ b/library/Icinga/Web/Widget/SingleValueSearchControl.php @@ -164,6 +164,12 @@ class SingleValueSearchControl extends Form $name ])); continue; + } elseif (empty($entries)) { + $ul->add(new HtmlElement('li', ['class' => 'failure-message'], [ + new HtmlElement('em', null, t('No results:')), + $name + ])); + continue; } else { $ul->add(new HtmlElement('li', ['class' => Suggestions::SUGGESTION_TITLE_CLASS], $name)); } From b48f7f348924d32a020960dd80890ed1ba2bc0e6 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 7 Apr 2021 16:00:01 +0200 Subject: [PATCH 31/33] role/audit: Don't use class `EmptyState`, it's from icingadb web --- application/controllers/RoleController.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/application/controllers/RoleController.php b/application/controllers/RoleController.php index b1e5e54f5..4e55ed318 100644 --- a/application/controllers/RoleController.php +++ b/application/controllers/RoleController.php @@ -11,7 +11,6 @@ use Icinga\Authentication\RolesConfig; use Icinga\Data\Selectable; use Icinga\Exception\NotFoundError; use Icinga\Forms\Security\RoleForm; -use Icinga\Module\Icingadb\Widget\EmptyState; use Icinga\Repository\Repository; use Icinga\Security\SecurityException; use Icinga\User; @@ -186,7 +185,7 @@ class RoleController extends AuthBackendController $this->addControl($form); if (! $name) { - $this->addContent(new EmptyState(t('No user or group selected.'))); + $this->addContent(Html::wantHtml(t('No user or group selected.'))); return; } From 54acd6b4c82dce5d4ff2d2951275f4bd2ea64ee0 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 7 Apr 2021 16:02:31 +0200 Subject: [PATCH 32/33] role/audit: Group suggestions by backend name --- application/controllers/RoleController.php | 41 +++++++++++++++------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/application/controllers/RoleController.php b/application/controllers/RoleController.php index 4e55ed318..e0fb3b4a3 100644 --- a/application/controllers/RoleController.php +++ b/application/controllers/RoleController.php @@ -18,6 +18,7 @@ use Icinga\Web\Controller\AuthBackendController; use Icinga\Web\View\PrivilegeAudit; use Icinga\Web\Widget\SingleValueSearchControl; use ipl\Html\Html; +use ipl\Html\HtmlString; use ipl\Web\Url; use ipl\Web\Widget\Link; @@ -258,7 +259,7 @@ class RoleController extends AuthBackendController $searchTerm = $requestData['term']['label']; $userBackends = $this->loadUserBackends(Selectable::class); - $users = []; + $suggestions = []; while ($limit > 0 && ! empty($userBackends)) { /** @var Repository $backend */ $backend = array_shift($userBackends); @@ -273,10 +274,22 @@ class RoleController extends AuthBackendController continue; } + $users = []; foreach ($names as $name) { - $users[$name] = [ + $users[] = [$name, [ 'type' => 'user', 'backend' => $backend->getName() + ]]; + } + + if (! empty($users)) { + $suggestions[] = [ + [ + t('Users'), + HtmlString::create(' '), + Html::tag('span', ['class' => 'badge'], $backend->getName()) + ], + $users ]; } @@ -285,7 +298,6 @@ class RoleController extends AuthBackendController $groupBackends = $this->loadUserGroupBackends(Selectable::class); - $groups = []; while ($limit > 0 && ! empty($groupBackends)) { /** @var Repository $backend */ $backend = array_shift($groupBackends); @@ -300,22 +312,25 @@ class RoleController extends AuthBackendController continue; } + $groups = []; foreach ($names as $name) { - $groups[$name] = ['type' => 'group']; + $groups[] = [$name, ['type' => 'group']]; + } + + if (! empty($groups)) { + $suggestions[] = [ + [ + t('Groups'), + HtmlString::create(' '), + Html::tag('span', ['class' => 'badge'], $backend->getName()) + ], + $groups + ]; } $limit -= count($names); } - $suggestions = []; - if (! empty($users)) { - $suggestions[t('Users')] = $users; - } - - if (! empty($groups)) { - $suggestions[t('Groups')] = $groups; - } - $this->document->add(SingleValueSearchControl::createSuggestions($suggestions)); } From 8e2ae13885f1e2b7b9ead77291e385606f1f6a28 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 7 Apr 2021 16:03:05 +0200 Subject: [PATCH 33/33] role/audit: Show a message if no suggestions are found --- application/controllers/RoleController.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/application/controllers/RoleController.php b/application/controllers/RoleController.php index e0fb3b4a3..c83b20e30 100644 --- a/application/controllers/RoleController.php +++ b/application/controllers/RoleController.php @@ -331,6 +331,10 @@ class RoleController extends AuthBackendController $limit -= count($names); } + if (empty($suggestions)) { + $suggestions[] = [t('Your search does not match any user or group'), []]; + } + $this->document->add(SingleValueSearchControl::createSuggestions($suggestions)); }