diff --git a/application/controllers/GroupController.php b/application/controllers/GroupController.php index 7e3da5ae6..d18397cf4 100644 --- a/application/controllers/GroupController.php +++ b/application/controllers/GroupController.php @@ -381,6 +381,16 @@ class GroupController extends AuthBackendController 'url' => 'role/list' ) ); + + $tabs->add( + 'role/audit', + [ + 'title' => $this->translate('Audit a user\'s or group\'s privileges'), + 'label' => $this->translate('Audit'), + 'url' => 'role/audit', + 'baseTarget' => '_main', + ] + ); } if ($this->hasPermission('config/access-control/users')) { diff --git a/application/controllers/RoleController.php b/application/controllers/RoleController.php index ea35fcf07..c83b20e30 100644 --- a/application/controllers/RoleController.php +++ b/application/controllers/RoleController.php @@ -3,11 +3,24 @@ 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\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\Html\HtmlString; +use ipl\Web\Url; +use ipl\Web\Widget\Link; /** * Manage user permissions and restrictions based on roles @@ -71,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(); @@ -90,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); @@ -113,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); @@ -127,6 +140,204 @@ 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'); + $this->view->title = t('Audit'); + + $roleName = $this->params->get('role'); + $type = $this->params->has('group') ? 'group' : 'user'; + $name = $this->params->get($type); + + $backend = null; + 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(); + $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( + 'role/suggest-role-member', + ['_disableLayout' => true, 'showCompact' => true] + )); + + $form->on(SingleValueSearchControl::ON_SUCCESS, function ($form) { + $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->addControl($form); + + if (! $name) { + $this->addContent(Html::wantHtml(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)) + ->addAttributes(['id' => 'role-audit']) + ); + } + + public function suggestRoleMemberAction() + { + $this->assertHttpMethod('POST'); + $requestData = $this->getRequest()->getPost(); + $limit = $this->params->get('limit', 50); + + $searchTerm = $requestData['term']['label']; + $userBackends = $this->loadUserBackends(Selectable::class); + + $suggestions = []; + while ($limit > 0 && ! empty($userBackends)) { + /** @var Repository $backend */ + $backend = array_shift($userBackends); + $query = $backend->select() + ->from('user', ['user_name']) + ->where('user_name', $searchTerm) + ->limit($limit); + + try { + $names = $query->fetchColumn(); + } catch (Exception $e) { + continue; + } + + $users = []; + foreach ($names as $name) { + $users[] = [$name, [ + 'type' => 'user', + 'backend' => $backend->getName() + ]]; + } + + if (! empty($users)) { + $suggestions[] = [ + [ + t('Users'), + HtmlString::create(' '), + Html::tag('span', ['class' => 'badge'], $backend->getName()) + ], + $users + ]; + } + + $limit -= count($names); + } + + $groupBackends = $this->loadUserGroupBackends(Selectable::class); + + 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; + } + + $groups = []; + foreach ($names as $name) { + $groups[] = [$name, ['type' => 'group']]; + } + + if (! empty($groups)) { + $suggestions[] = [ + [ + t('Groups'), + HtmlString::create(' '), + Html::tag('span', ['class' => 'badge'], $backend->getName()) + ], + $groups + ]; + } + + $limit -= count($names); + } + + if (empty($suggestions)) { + $suggestions[] = [t('Your search does not match any user or group'), []]; + } + + $this->document->add(SingleValueSearchControl::createSuggestions($suggestions)); + } + /** * Create the tabs to display when listing roles */ @@ -145,6 +356,16 @@ 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', + 'baseTarget' => '_main' + ] + ); + 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..b011cdfb4 100644 --- a/application/controllers/UserController.php +++ b/application/controllers/UserController.php @@ -338,6 +338,16 @@ class UserController extends AuthBackendController 'url' => 'role/list' ) ); + + $tabs->add( + 'role/audit', + [ + 'title' => $this->translate('Audit a user\'s or group\'s privileges'), + 'label' => $this->translate('Audit'), + 'url' => 'role/audit', + 'baseTarget' => '_main' + ] + ); } $tabs->add( diff --git a/application/forms/Security/RoleForm.php b/application/forms/Security/RoleForm.php index a2eaf0ea3..ff44e820f 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'] @@ -298,13 +171,25 @@ 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 - '~(? isset($spec['description']) ? $spec['description'] : $spec['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), [['Callback', ['callback' => function () use ($denyCheckbox) { @@ -320,11 +205,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 +222,36 @@ 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 - '~(? 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 ] ) - ->getElement($name) + ->getElement($elementName) ->getDecorator('Label') ->setOption('escape', false); } @@ -402,11 +301,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 +314,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 +329,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 +345,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 +382,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 +467,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]; + } } 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); diff --git a/library/Icinga/Authentication/Auth.php b/library/Icinga/Authentication/Auth.php index 28d2e0ba1..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 { @@ -98,97 +99,23 @@ 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(); - } - // 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; - } + $this->setupUser($user); - 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; + // 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); } - 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 +336,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); + } } 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; 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 */ 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..29988f8cd --- /dev/null +++ b/library/Icinga/Web/View/PrivilegeAudit.php @@ -0,0 +1,545 @@ + '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; + $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; + $connector->getAttributes()->add('class', 'initiator'); + } + + $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 ($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, + $setInitiator ? '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; + } + + foreach ($this->collectRestrictions($role, $restriction) as $role => $roleRestriction) { + $restrictedBy[] = $role; + $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]->getName(), + $restrictedBy[1]->getName(), + count($restrictedBy) - 2 + ) + : sprintf( + tp('Restricted by %s', 'Restricted by %s and %s', count($restrictedBy)), + ...array_map(function ($role) { + return $role->getName(); + }, $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(' | ', array_map(function ($role) { + return $role->getName(); + }, $restrictedBy))), + new HtmlElement('code', ['class' => 'restriction'], $combinedRestrictions) + ]), + $combinedLinks ? new HtmlElement('div', ['class' => 'previews'], [ + new HtmlElement('em', null, t('Previews:')), + $combinedLinks + ]) : null + ]); + } + + foreach ($restrictedBy as $role) { + 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 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 + 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/library/Icinga/Web/Widget/SingleValueSearchControl.php b/library/Icinga/Web/Widget/SingleValueSearchControl.php new file mode 100644 index 000000000..99bbc5c4d --- /dev/null +++ b/library/Icinga/Web/Widget/SingleValueSearchControl.php @@ -0,0 +1,194 @@ + 'icinga-controls inline']; + + /** @var string */ + protected $searchParameter = self::DEFAULT_SEARCH_PARAMETER; + + /** @var string */ + protected $inputLabel; + + /** @var string */ + protected $submitLabel; + + /** @var Url */ + protected $suggestionUrl; + + /** @var array */ + protected $metaDataNames; + + /** + * 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; + } + + /** + * 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'); + + $this->addElement( + 'text', + $this->searchParameter, + [ + 'required' => true, + 'minlength' => 1, + 'autocomplete' => 'off', + 'class' => 'search', + 'data-enrichment-type' => 'completion', + 'data-term-suggestions' => '#' . $suggestionsId, + 'data-suggest-url' => $this->suggestionUrl, + 'placeholder' => $this->inputLabel + ] + ); + + 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', + [ + 'label' => $this->submitLabel, + 'class' => 'btn-primary' + ] + ); + + $this->add(new HtmlElement('div', [ + 'id' => $suggestionsId, + 'class' => 'search-suggestions' + ])); + } + + /** + * Create a list of search suggestions based on the given groups + * + * @param array $groups + * + * @return HtmlElement + */ + public static function createSuggestions(array $groups) + { + $ul = new HtmlElement('ul'); + 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:')), + $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)); + } + } + + foreach ($entries as list($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; + } +} diff --git a/public/css/icinga/audit.less b/public/css/icinga/audit.less new file mode 100644 index 000000000..422e9c800 --- /dev/null +++ b/public/css/icinga/audit.less @@ -0,0 +1,366 @@ +// 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; + } + + 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-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; + 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); + } +} + +#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 { + .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"; + } +} 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/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; } 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 15bee5cda..005d4bb4f 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; } @@ -386,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 6cd08df04..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 @@ -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; 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; } } }