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')); + } }