Auth: Add support for denied permissions

This commit is contained in:
Johannes Meyer 2021-01-29 15:48:48 +01:00
parent b2f7c3788d
commit 87d741265e
7 changed files with 215 additions and 30 deletions

View File

@ -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' => '<h4>' . $this->translate('Permissions') . '</h4>',
'decorators' => ['ViewHelper']
'decorators' => [['Callback', ['callback' => function () {
return '<h4>' . $this->translate('Permissions') . '</h4>'
. $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
'/&#8203;',
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('~(?<!/)\*~', $role->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);
}

View File

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

View File

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

View File

@ -22,6 +22,7 @@ class RolesConfig extends IniRepository
'name',
'users',
'groups',
'refusals',
'permissions',
'application/share/users',
'application/share/groups'

View File

@ -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;
}
/**

View File

@ -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 {

View File

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