diff --git a/application/forms/Security/RoleForm.php b/application/forms/Security/RoleForm.php
index b50cb8f8b..bb2e237eb 100644
--- a/application/forms/Security/RoleForm.php
+++ b/application/forms/Security/RoleForm.php
@@ -23,6 +23,11 @@ class RoleForm extends RepositoryForm
*/
const WILDCARD_NAME = 'allAndEverything';
+ /**
+ * The prefix used to deny a permission
+ */
+ const DENY_PREFIX = 'no-';
+
/**
* Provided permissions by currently installed modules
*
@@ -196,8 +201,15 @@ class RoleForm extends RepositoryForm
$elements[] = 'permission_header';
$this->addElement('note', 'permission_header', [
- 'value' => '
' . $this->translate('Permissions') . '
',
- 'decorators' => ['ViewHelper']
+ 'decorators' => [['Callback', ['callback' => function () {
+ return '' . $this->translate('Permissions') . '
'
+ . $this->getView()->icon('ok', $this->translate(
+ 'Grant access by toggling a switch below'
+ ))
+ . $this->getView()->icon('cancel', $this->translate(
+ 'Deny access by toggling a switch below'
+ ));
+ }]], ['HtmlTag', ['tag' => 'div']]]
]);
$hasFullPerm = false;
@@ -207,6 +219,17 @@ class RoleForm extends RepositoryForm
$elementName .= '_fake';
}
+ $denyCheckbox = null;
+ if (! isset($spec['isFullPerm'])
+ && substr($spec['name'], 0, strlen(self::DENY_PREFIX)) !== self::DENY_PREFIX
+ ) {
+ $denyCheckbox = $this->createElement('checkbox', self::DENY_PREFIX . $name, [
+ 'decorators' => ['ViewHelper']
+ ]);
+ $this->addElement($denyCheckbox);
+ $this->removeFromIteration($denyCheckbox->getName());
+ }
+
$elements[] = $elementName;
$this->addElement(
'checkbox',
@@ -222,7 +245,14 @@ class RoleForm extends RepositoryForm
'/',
isset($spec['label']) ? $spec['label'] : $spec['name']
),
- 'description' => isset($spec['description']) ? $spec['description'] : $spec['name']
+ 'description' => isset($spec['description']) ? $spec['description'] : $spec['name'],
+ 'decorators' => array_merge(
+ array_slice(self::$defaultElementDecorators, 0, 3),
+ [['Callback', ['callback' => function () use ($denyCheckbox) {
+ return $denyCheckbox ? $denyCheckbox->render() : '';
+ }]]],
+ array_slice(self::$defaultElementDecorators, 3)
+ )
]
)
->getElement($elementName)
@@ -298,13 +328,18 @@ class RoleForm extends RepositoryForm
self::WILDCARD_NAME => (bool) preg_match('~(?permissions)
];
- if (! empty($role->permissions) && $role->permissions !== '*') {
+ if (! empty($role->permissions) || ! empty($role->refusals)) {
$permissions = StringHelper::trimSplit($role->permissions);
+ $refusals = StringHelper::trimSplit($role->refusals);
foreach ($this->providedPermissions as $moduleName => $permissionList) {
foreach ($permissionList as $name => $spec) {
if (in_array($spec['name'], $permissions, true)) {
$values[$name] = 1;
}
+
+ if (in_array($spec['name'], $refusals, true)) {
+ $values[$this->filterName(self::DENY_PREFIX . $name)] = 1;
+ }
}
}
}
@@ -338,17 +373,24 @@ class RoleForm extends RepositoryForm
$permissions[] = '*';
}
+ $refusals = [];
foreach ($this->providedPermissions as $moduleName => $permissionList) {
foreach ($permissionList as $name => $spec) {
if (isset($values[$name]) && $values[$name]) {
$permissions[] = $spec['name'];
}
- unset($values[$name]);
+ $denyName = $this->filterName(self::DENY_PREFIX . $name);
+ if (isset($values[$denyName]) && $values[$denyName]) {
+ $refusals[] = $spec['name'];
+ }
+
+ unset($values[$name], $values[$denyName]);
}
}
unset($values[self::WILDCARD_NAME]);
+ $values['refusals'] = join(',', $refusals);
$values['permissions'] = join(',', $permissions);
return ConfigForm::transformEmptyValuesToNull($values);
}
diff --git a/library/Icinga/Authentication/AdmissionLoader.php b/library/Icinga/Authentication/AdmissionLoader.php
index 8ee43dbfb..b9a9919db 100644
--- a/library/Icinga/Authentication/AdmissionLoader.php
+++ b/library/Icinga/Authentication/AdmissionLoader.php
@@ -71,6 +71,7 @@ class AdmissionLoader
foreach ($roles as $roleName => $role) {
if ($this->match($username, $userGroups, $role)) {
$permissionsFromRole = StringHelper::trimSplit($role->permissions);
+ $refusals = StringHelper::trimSplit($role->refusals);
$permissions = array_merge(
$permissions,
array_diff($permissionsFromRole, $permissions)
@@ -78,6 +79,7 @@ class AdmissionLoader
$restrictionsFromRole = $role->toArray();
unset($restrictionsFromRole['users']);
unset($restrictionsFromRole['groups']);
+ unset($restrictionsFromRole['refusals']);
unset($restrictionsFromRole['permissions']);
foreach ($restrictionsFromRole as $name => $restriction) {
if (! isset($restrictions[$name])) {
@@ -89,6 +91,7 @@ class AdmissionLoader
$roleObj = new Role();
$roleObjs[] = $roleObj
->setName($roleName)
+ ->setRefusals($refusals)
->setPermissions($permissionsFromRole)
->setRestrictions($restrictionsFromRole);
}
diff --git a/library/Icinga/Authentication/Role.php b/library/Icinga/Authentication/Role.php
index acebae26a..a1d166f64 100644
--- a/library/Icinga/Authentication/Role.php
+++ b/library/Icinga/Authentication/Role.php
@@ -17,14 +17,21 @@ class Role
*
* @var string[]
*/
- protected $permissions = array();
+ protected $permissions = [];
+
+ /**
+ * Refusals of the role
+ *
+ * @var string[]
+ */
+ protected $refusals = [];
/**
* Restrictions of the role
*
* @var string[]
*/
- protected $restrictions = array();
+ protected $restrictions = [];
/**
* Get the name of the role
@@ -46,6 +53,7 @@ class Role
public function setName($name)
{
$this->name = $name;
+
return $this;
}
@@ -69,6 +77,31 @@ class Role
public function setPermissions(array $permissions)
{
$this->permissions = $permissions;
+
+ return $this;
+ }
+
+ /**
+ * Get the refusals of the role
+ *
+ * @return string[]
+ */
+ public function getRefusals()
+ {
+ return $this->refusals;
+ }
+
+ /**
+ * Set the refusals of the role
+ *
+ * @param array $refusals
+ *
+ * @return $this
+ */
+ public function setRefusals(array $refusals)
+ {
+ $this->refusals = $refusals;
+
return $this;
}
@@ -104,6 +137,7 @@ class Role
public function setRestrictions(array $restrictions)
{
$this->restrictions = $restrictions;
+
return $this;
}
@@ -116,31 +150,67 @@ class Role
*/
public function grants($permission)
{
- $requiredWildcard = strpos($permission, '*');
foreach ($this->permissions as $grantedPermission) {
- if ($grantedPermission === '*' || $grantedPermission === $permission) {
- return true;
- }
-
- if ($requiredWildcard !== false) {
- if (($grantedWildcard = strpos($grantedPermission, '*')) !== false) {
- $wildcard = min($requiredWildcard, $grantedWildcard);
- } else {
- $wildcard = $requiredWildcard;
- }
- } else {
- $wildcard = strpos($grantedPermission, '*');
- }
-
- if ($wildcard !== false && $wildcard > 0) {
- if (substr($permission, 0, $wildcard) === substr($grantedPermission, 0, $wildcard)) {
- return true;
- }
- } elseif ($permission === $grantedPermission) {
+ if ($this->match($grantedPermission, $permission)) {
return true;
}
}
return false;
}
+
+ /**
+ * Whether this role denies the given permission
+ *
+ * @param string $permission
+ *
+ * @return bool
+ */
+ public function denies($permission)
+ {
+ foreach ($this->refusals as $refusedPermission) {
+ if ($this->match($refusedPermission, $permission, false)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get whether the role expression matches the required permission
+ *
+ * @param string $roleExpression
+ * @param string $requiredPermission
+ * @param bool $cascadeUpwards `false` if `foo/bar/*` and `foo/bar/raboof` should not match `foo/*`
+ *
+ * @return bool
+ */
+ protected function match($roleExpression, $requiredPermission, $cascadeUpwards = true)
+ {
+ if ($roleExpression === '*' || $roleExpression === $requiredPermission) {
+ return true;
+ }
+
+ $requiredWildcard = strpos($requiredPermission, '*');
+ if ($requiredWildcard !== false) {
+ if (($grantedWildcard = strpos($roleExpression, '*')) !== false) {
+ $wildcard = $cascadeUpwards ? min($requiredWildcard, $grantedWildcard) : $grantedWildcard;
+ } else {
+ $wildcard = $cascadeUpwards ? $requiredWildcard : false;
+ }
+ } else {
+ $wildcard = strpos($roleExpression, '*');
+ }
+
+ if ($wildcard !== false && $wildcard > 0) {
+ if (substr($requiredPermission, 0, $wildcard) === substr($roleExpression, 0, $wildcard)) {
+ return true;
+ }
+ } elseif ($requiredPermission === $roleExpression) {
+ return true;
+ }
+
+ return false;
+ }
}
diff --git a/library/Icinga/Authentication/RolesConfig.php b/library/Icinga/Authentication/RolesConfig.php
index 51f07cc3b..a0aeee823 100644
--- a/library/Icinga/Authentication/RolesConfig.php
+++ b/library/Icinga/Authentication/RolesConfig.php
@@ -22,6 +22,7 @@ class RolesConfig extends IniRepository
'name',
'users',
'groups',
+ 'refusals',
'permissions',
'application/share/users',
'application/share/groups'
diff --git a/library/Icinga/User.php b/library/Icinga/User.php
index c5652ab62..833a4c84b 100644
--- a/library/Icinga/User.php
+++ b/library/Icinga/User.php
@@ -563,13 +563,18 @@ class User
*/
public function can($requiredPermission)
{
+ $granted = false;
foreach ($this->getRoles() as $role) {
- if ($role->grants($requiredPermission)) {
- return true;
+ if ($role->denies($requiredPermission)) {
+ return false;
+ }
+
+ if (! $granted && $role->grants($requiredPermission)) {
+ $granted = true;
}
}
- return false;
+ return $granted;
}
/**
diff --git a/public/css/icinga/widgets.less b/public/css/icinga/widgets.less
index 8ae3efcae..d8410178b 100644
--- a/public/css/icinga/widgets.less
+++ b/public/css/icinga/widgets.less
@@ -242,7 +242,26 @@ form.role-form {
}
h4 {
+ display: inline-block;
+ width: 20em;
margin-top: 1.5em;
+ padding-right: .5625em;
+ text-align: right;
+
+ & ~ i {
+ display: inline-block;
+ width: 2.625em;
+ margin-right: 1em;
+ text-align: center;
+
+ &.icon-ok {
+ color: @color-ok;
+ }
+
+ &.icon-cancel {
+ color: @color-critical;
+ }
+ }
}
.collapsible-control {
diff --git a/test/php/library/Icinga/UserTest.php b/test/php/library/Icinga/UserTest.php
index d73ed6b43..7798aee50 100644
--- a/test/php/library/Icinga/UserTest.php
+++ b/test/php/library/Icinga/UserTest.php
@@ -76,6 +76,7 @@ class UserTest extends BaseTestCase
$user->setRoles([$role]);
$this->assertTrue($user->can('test'));
+ $this->assertTrue($user->can('test/some/*'));
$this->assertTrue($user->can('test/some/specific'));
$this->assertTrue($user->can('test/more/everything'));
$this->assertTrue($user->can('test/wildcard-with-wildcard/*'));
@@ -85,4 +86,48 @@ class UserTest extends BaseTestCase
$this->assertFalse($user->can('test/some/not/so/specific'));
$this->assertFalse($user->can('test/wildcard2/*'));
}
+
+ public function testRefusals()
+ {
+ $role = new Role();
+ $role->setPermissions([
+ 'a',
+ 'a/b/*',
+ 'a/b/c/d',
+ 'c/*',
+ 'd/*'
+ ]);
+ $role->setRefusals([
+ 'a/b/c',
+ 'a/b/e',
+ 'c/b/a',
+ 'c/d/*',
+ 'd/f',
+ 'e/g'
+ ]);
+
+ $user = new User('test');
+ $user->setRoles([$role]);
+
+ $this->assertFalse($user->can('a/b/c'));
+ $this->assertFalse($user->can('a/b/e'));
+ $this->assertTrue($user->can('a/b/d'));
+ $this->assertTrue($user->can('a/b/c/d'));
+ $this->assertFalse($user->can('c/b/a'));
+ $this->assertTrue($user->can('c/b/d'));
+ $this->assertFalse($user->can('c/d/u'));
+ $this->assertFalse($user->can('c/d/*'));
+ $this->assertTrue($user->can('c/*'));
+ $this->assertTrue($user->can('d/*'));
+ $this->assertFalse($user->can('e/*'));
+
+ $secondRole = new Role();
+ $role->setRefusals(['a/b/*']);
+
+ $user->setRoles([$role, $secondRole]);
+
+ $this->assertFalse($user->can('a/b/d'));
+ $this->assertFalse($user->can('a/b/c/d'));
+ $this->assertTrue($user->can('c/b/d'));
+ }
}