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;
}
}
}