Introduce concept of system roles

These are roles defined by a special API code, once a system role code is detected, the role becomes locked and its permissions are sourced from the AuthManager. All permissions are granted to system roles by default, unless otherwise specified. This should make it easier to create client accounts as "Publishers", hiding developer tools like the CMS and Builder plugins by default.
This commit is contained in:
Samuel Georges 2017-07-13 19:29:50 +10:00
parent 358a6337df
commit 4fd1ca824f
17 changed files with 237 additions and 57 deletions

View File

@ -31,6 +31,7 @@ class AuthManager extends RainAuthManager
'code' => null,
'label' => null,
'comment' => null,
'roles' => null,
'order' => 500
];
@ -44,6 +45,11 @@ class AuthManager extends RainAuthManager
*/
protected $permissions = [];
/**
* @var array List of registered permission roles.
*/
protected $permissionRoles = false;
/**
* @var array Cache of registered permissions.
*/
@ -157,4 +163,40 @@ class AuthManager extends RainAuthManager
return $tabs;
}
/**
* Returns an array of registered permissions belonging to a given role code
* @param string $role
* @return array
*/
public function listPermissionsForRole($role, $includeOrphans = true)
{
if ($this->permissionRoles === false) {
$this->permissionRoles = [];
foreach ($this->listPermissions() as $permission) {
if ($permission->roles) {
foreach ((array) $permission->roles as $_role) {
$this->permissionRoles[$_role][$permission->code] = 1;
}
}
else {
$this->permissionRoles['*'][$permission->code] = 1;
}
}
}
$result = $this->permissionRoles[$role] ?? [];
if ($includeOrphans) {
$result += $this->permissionRoles['*'] ?? [];
}
return $result;
}
public function hasPermissionsForRole($role)
{
return !!$this->listPermissionsForRole($role, false);
}
}

View File

@ -1,5 +1,7 @@
<?php namespace Backend\Controllers;
use View;
use Response;
use BackendMenu;
use BackendAuth;
use Backend\Classes\Controller;
@ -30,6 +32,15 @@ class UserRoles extends Controller
BackendMenu::setContext('October.System', 'system', 'users');
SettingsManager::setContext('October.System', 'administrators');
/*
* Only super users can access
*/
$this->bindEvent('page.beforeDisplay', function() {
if (!$this->user->isSuperUser()) {
return Response::make(View::make('backend::access_denied'), 403);
}
});
}
/**

View File

@ -95,6 +95,7 @@ class Users extends Controller
if (!$this->user->isSuperUser()) {
$form->removeField('is_superuser');
$form->removeField('role');
}
/*

View File

@ -7,7 +7,8 @@ list: ~/modules/backend/models/usergroup/columns.yaml
modelClass: Backend\Models\UserGroup
recordUrl: backend/usergroups/update/:id
noRecordsMessage: backend::lang.list.no_records
recordsPerPage: 5
recordsPerPage: 25
showSetup: true
toolbar:
buttons: list_toolbar

View File

@ -7,7 +7,8 @@ list: ~/modules/backend/models/userrole/columns.yaml
modelClass: Backend\Models\UserRole
recordUrl: backend/userroles/update/:id
noRecordsMessage: backend::lang.list.no_records
recordsPerPage: 5
recordsPerPage: 25
showSetup: true
toolbar:
buttons: list_toolbar

View File

@ -2,9 +2,11 @@
<a href="<?= Backend::url('backend/users/create') ?>" class="btn btn-primary oc-icon-plus">
<?= e(trans('backend::lang.user.new')) ?>
</a>
<?php if ($this->user->isSuperUser()): ?>
<a href="<?= Backend::url('backend/userroles') ?>" class="btn btn-default oc-icon-address-card">
<?= e(trans('backend::lang.user.role.list_title')) ?>
</a>
<?php endif ?>
<a href="<?= Backend::url('backend/usergroups') ?>" class="btn btn-default oc-icon-group">
<?= e(trans('backend::lang.user.group.list_title')) ?>
</a>

View File

@ -1,5 +1,6 @@
<?php
use Backend\Models\UserRole;
use October\Rain\Database\Schema\Blueprint;
use October\Rain\Database\Updates\Migration;
@ -14,6 +15,7 @@ class DbBackendUserRoles extends Migration
$table->string('code')->nullable()->index('role_code_index');
$table->text('description')->nullable();
$table->text('permissions')->nullable();
$table->boolean('is_system')->default(0);
$table->timestamps();
});
@ -33,11 +35,12 @@ class DbBackendUserRoles extends Migration
// Role not found in the users table, perform a complete migration.
// Merging group permissions with the user and assigning the user
// with the first available role.
if (Schema::hasColumn('backend_users', 'role_id')) {
if (!Schema::hasColumn('backend_users', 'role_id')) {
Schema::table('backend_users', function (Blueprint $table) {
$table->integer('role_id')->unsigned()->nullable()->index('admin_role_index');
});
$this->createSystemUserRoles();
$this->migratePermissionsFromGroupsToRoles();
}
@ -49,6 +52,21 @@ class DbBackendUserRoles extends Migration
}
}
protected function createSystemUserRoles()
{
Db::table('backend_user_roles')->insert([
'name' => 'Publisher',
'code' => UserRole::CODE_PUBLISHER,
'description' => 'Site editor with access to publishing tools.',
]);
Db::table('backend_user_roles')->insert([
'name' => 'Developer',
'code' => UserRole::CODE_DEVELOPER,
'description' => 'Site administrator with access to developer tools.',
]);
}
protected function migratePermissionsFromGroupsToRoles()
{
$groups = Db::table('backend_user_groups')->get();
@ -66,6 +84,7 @@ class DbBackendUserRoles extends Migration
try {
$roles[$group->id] = Db::table('backend_user_roles')->insertGetId([
'name' => $group->name,
'description' => $group->description,
'permissions' => $group->permissions ?? null
]);
}

View File

@ -5,7 +5,6 @@ use Eloquent;
class DatabaseSeeder extends Seeder
{
/**
* Run the database seeds.
*

View File

@ -2,11 +2,11 @@
use Seeder;
use Backend\Models\User;
use Backend\Models\UserRole;
use Backend\Models\UserGroup;
class SeedSetupAdmin extends Seeder
{
public static $email = 'admin@domain.tld';
public static $login = 'admin';
public static $password = 'admin';
@ -25,9 +25,21 @@ class SeedSetupAdmin extends Seeder
public function run()
{
UserRole::create([
'name' => 'Publisher',
'code' => UserRole::CODE_PUBLISHER,
'description' => 'Site editor with access to publishing tools.',
]);
$role = UserRole::create([
'name' => 'Developer',
'code' => UserRole::CODE_DEVELOPER,
'description' => 'Site administrator with access to developer tools.',
]);
$group = UserGroup::create([
'name' => 'Owners',
'code' => UserGroup::DEFAULT_CODE,
'code' => UserGroup::CODE_OWNERS,
'description' => 'Default group for website owners.',
'is_new_user_default' => false
]);
@ -41,10 +53,10 @@ class SeedSetupAdmin extends Seeder
'last_name' => static::$lastName,
'permissions' => [],
'is_superuser' => true,
'is_activated' => true
'is_activated' => true,
'role_id' => $role->id
]);
$user->addGroup($group);
}
}

View File

@ -12,6 +12,8 @@ use BackendAuth;
*/
class PermissionEditor extends FormWidgetBase
{
protected $user;
public $mode;
/**
@ -22,6 +24,8 @@ class PermissionEditor extends FormWidgetBase
$this->fillFromConfig([
'mode'
]);
$this->user = BackendAuth::getUser();
}
/**
@ -38,6 +42,10 @@ class PermissionEditor extends FormWidgetBase
*/
public function prepareVars()
{
if ($this->formField->disabled) {
$this->previewMode = true;
}
$permissionsData = $this->formField->getValueFromData($this->model);
if (!is_array($permissionsData)) {
$permissionsData = [];
@ -54,6 +62,36 @@ class PermissionEditor extends FormWidgetBase
* @inheritDoc
*/
public function getSaveValue($value)
{
if ($this->user->isSuperUser()) {
return is_array($value) ? $value : [];
}
return $this->getSaveValueSecure($value);
}
/**
* @inheritDoc
*/
protected function loadAssets()
{
$this->addCss('css/permissioneditor.css', 'core');
$this->addJs('js/permissioneditor.js', 'core');
}
protected function getControlMode()
{
return strlen($this->mode) ? $this->mode : 'radio';
}
/**
* Returns a safely parsed set of permissions, ensuring the user cannot elevate
* their own permissions or permissions of another user above their own.
*
* @param string $value
* @return array
*/
protected function getSaveValueSecure($value)
{
$newPermissions = is_array($value) ? array_map('intval', $value) : [];
@ -80,20 +118,6 @@ class PermissionEditor extends FormWidgetBase
return $newPermissions;
}
/**
* @inheritDoc
*/
protected function loadAssets()
{
$this->addCss('css/permissioneditor.css', 'core');
$this->addJs('js/permissioneditor.js', 'core');
}
protected function getControlMode()
{
return strlen($this->mode) ? $this->mode : 'radio';
}
/**
* Returns the available permissions; removing those that the logged-in user does not have access to
*
@ -102,17 +126,22 @@ class PermissionEditor extends FormWidgetBase
protected function getFilteredPermissions()
{
$permissions = BackendAuth::listTabbedPermissions();
$user = BackendAuth::getUser();
if ($this->user->isSuperUser()) {
return $permissions;
}
foreach ($permissions as $tab => $permissionsArray) {
foreach ($permissionsArray as $index => $permission) {
if (!$user->hasAccess($permission->code)) {
if (!$this->user->hasAccess($permission->code)) {
unset($permissionsArray[$index]);
}
}
if (empty($permissionsArray)) {
unset($permissions[$tab]);
} else {
}
else {
$permissions[$tab] = $permissionsArray;
}
}

View File

@ -1,29 +1,33 @@
<div class="permissioneditor" <?= $field->getAttributes() ?>>
<div class="permissioneditor <?= $this->previewMode ? 'control-disabled' : '' ?>" <?= $field->getAttributes() ?>>
<table>
<?php
$firstTab = true;
$globalIndex = 0;
foreach ($permissions as $tab=>$tabPermissions): ?>
?>
<?php foreach ($permissions as $tab => $tabPermissions): ?>
<tr class="section">
<th class="tab"><?= e(trans($tab)) ?></th>
<th class="permission-type"><?= $firstTab ? e(Lang::get('backend::lang.user.allow')) : '' ?></th>
<th class="permission-type"><?= $firstTab ? e(trans('backend::lang.user.allow')) : '' ?></th>
<?php if (!$checkboxMode): ?>
<th class="permission-type"><?= $firstTab ? e(Lang::get('backend::lang.user.inherit')) : '' ?></th>
<th class="permission-type"><?= $firstTab ? e(Lang::get('backend::lang.user.deny')) : '' ?></th>
<th class="permission-type"><?= $firstTab ? e(trans('backend::lang.user.inherit')) : '' ?></th>
<th class="permission-type"><?= $firstTab ? e(trans('backend::lang.user.deny')) : '' ?></th>
<?php endif ?>
<th></th>
</tr>
<?php
$lastIndex = count($tabPermissions)-1;
foreach ($tabPermissions as $index=>$permission):
$lastIndex = count($tabPermissions) - 1;
?>
<?php foreach ($tabPermissions as $index => $permission): ?>
<?php
$globalIndex++;
if (!$checkboxMode) {
$permissionValue = array_key_exists($permission->code, $permissionsData) ?
$permissionsData[$permission->code] : 0;
$permissionValue = array_key_exists($permission->code, $permissionsData)
? $permissionsData[$permission->code]
: 0;
}
else {
$isChecked = array_key_exists($permission->code, $permissionsData);
@ -103,7 +107,8 @@
<?php endforeach ?>
<?php
$firstTab = false;
endforeach ?>
?>
<?php endforeach ?>
</table>
<div class="permissions-overlay"></div>
</div>

View File

@ -141,7 +141,7 @@ return [
'group' => [
'name' => 'Group',
'name_field' => 'Name',
'name_comment' => 'The name is displayed in the group list on the Create/Edit Administrator form.',
'name_comment' => 'The name is displayed in the group list on the Administrator form.',
'description_field' => 'Description',
'is_new_user_default_field_label' => 'Default group',
'is_new_user_default_field_comment' => 'Add new administrators to this group by default',
@ -157,7 +157,7 @@ return [
'role' => [
'name' => 'Role',
'name_field' => 'Name',
'name_comment' => 'The name is displayed in the role list on the Create/Edit Administrator form.',
'name_comment' => 'The name is displayed in the role list on the Administrator form.',
'description_field' => 'Description',
'code_field' => 'Code',
'code_comment' => 'Enter a unique code if you want to access the role object with the API.',

View File

@ -10,10 +10,7 @@ use October\Rain\Auth\Models\Group as GroupBase;
*/
class UserGroup extends GroupBase
{
/**
* @var string The default group code.
*/
const DEFAULT_CODE = 'owners';
const CODE_OWNERS = 'owners';
/**
* @var string The database table used by the model.

View File

@ -1,5 +1,6 @@
<?php namespace Backend\Models;
use Backend\Classes\AuthManager;
use October\Rain\Auth\Models\Role as RoleBase;
/**
@ -10,10 +11,8 @@ use October\Rain\Auth\Models\Role as RoleBase;
*/
class UserRole extends RoleBase
{
/**
* @var string The default role code.
*/
const DEFAULT_CODE = 'default';
const CODE_DEVELOPER = 'developer';
const CODE_PUBLISHER = 'publisher';
/**
* @var string The database table used by the model.
@ -25,6 +24,7 @@ class UserRole extends RoleBase
*/
public $rules = [
'name' => 'required|between:2,128|unique:backend_user_roles',
'code' => 'unique:backend_user_roles',
];
/**
@ -34,4 +34,48 @@ class UserRole extends RoleBase
'users' => [User::class, 'key' => 'role_id'],
'users_count' => [User::class, 'key' => 'role_id', 'count' => true]
];
public function filterFields($fields)
{
if ($this->is_system) {
$fields->code->disabled = true;
$fields->permissions->disabled = true;
}
}
public function afterFetch()
{
if ($this->is_system) {
$this->permissions = $this->getDefaultPermissions();
}
}
public function beforeSave()
{
if ($this->isSystemRole()) {
$this->is_system = true;
$this->permissions = [];
}
}
public function isSystemRole()
{
if (!$this->code || !strlen(trim($this->code))) {
return false;
}
if ($this->is_system || in_array($this->code, [
self::CODE_DEVELOPER,
self::CODE_PUBLISHER
])) {
return true;
}
return AuthManager::instance()->hasPermissionsForRole($this->code);
}
public function getDefaultPermissions()
{
return AuthManager::instance()->listPermissionsForRole($this->code);
}
}

View File

@ -5,11 +5,16 @@
columns:
name:
label: backend::lang.user.group.name_field
searchable: yes
searchable: true
code:
label: backend::lang.user.group.code_field
searchable: true
invisible: true
description:
label: backend::lang.user.group.description_field
searchable: yes
searchable: true
users_count:
label: backend::lang.user.group.users_count

View File

@ -5,7 +5,12 @@
columns:
name:
label: backend::lang.user.role.name_field
searchable: yes
searchable: true
code:
label: backend::lang.user.role.code_field
searchable: true
invisible: true
description:
label: backend::lang.user.role.description_field

View File

@ -6,6 +6,7 @@ use Event;
use Backend;
use BackendMenu;
use BackendAuth;
use Backend\Models\UserRole;
use Backend\Classes\WidgetManager;
use October\Rain\Support\ModuleServiceProvider;
use System\Classes\SettingsManager;
@ -123,6 +124,7 @@ class ServiceProvider extends ModuleServiceProvider
'icon' => 'icon-magic',
'iconSvg' => 'modules/cms/assets/images/cms-icon.svg',
'url' => Backend::url('cms'),
'order' => 100,
'permissions' => [
'cms.manage_content',
'cms.manage_assets',
@ -130,7 +132,6 @@ class ServiceProvider extends ModuleServiceProvider
'cms.manage_layouts',
'cms.manage_partials'
],
'order' => 100,
'sideMenu' => [
'pages' => [
'label' => 'cms::lang.page.menu_label',
@ -216,31 +217,37 @@ class ServiceProvider extends ModuleServiceProvider
'cms.manage_content' => [
'label' => 'cms::lang.permissions.manage_content',
'tab' => 'cms::lang.permissions.name',
'roles' => UserRole::CODE_DEVELOPER,
'order' => 100
],
'cms.manage_assets' => [
'label' => 'cms::lang.permissions.manage_assets',
'tab' => 'cms::lang.permissions.name',
'roles' => UserRole::CODE_DEVELOPER,
'order' => 100
],
'cms.manage_pages' => [
'label' => 'cms::lang.permissions.manage_pages',
'tab' => 'cms::lang.permissions.name',
'roles' => UserRole::CODE_DEVELOPER,
'order' => 100
],
'cms.manage_layouts' => [
'label' => 'cms::lang.permissions.manage_layouts',
'tab' => 'cms::lang.permissions.name',
'roles' => UserRole::CODE_DEVELOPER,
'order' => 100
],
'cms.manage_partials' => [
'label' => 'cms::lang.permissions.manage_partials',
'tab' => 'cms::lang.permissions.name',
'roles' => UserRole::CODE_DEVELOPER,
'order' => 100
],
'cms.manage_themes' => [
'label' => 'cms::lang.permissions.manage_themes',
'tab' => 'cms::lang.permissions.name',
'roles' => UserRole::CODE_DEVELOPER,
'order' => 100
],
'media.manage_media' => [