From 99afe3f651ff50e5aa69a682ede78cafeec494d3 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Sun, 6 Nov 2022 12:41:52 +0100 Subject: [PATCH] Permissions refactoring * Migration: Added groups, privileges, user_groups, group_privileges, improved references * Models: Added Group, Privilege and integrated it into User * Replaced old permission handling with new models --- db/factories/GroupFactory.php | 22 ++ db/factories/PrivilegeFactory.php | 23 ++ ...e_privileges_and_groups_related_tables.php | 274 ++++++++++++++++++ db/migrations/Reference.php | 33 ++- includes/controller/users_controller.php | 2 +- includes/includes.php | 2 - includes/model/UserAngelTypes_model.php | 2 +- includes/model/UserGroups_model.php | 23 -- includes/pages/admin_groups.php | 90 +++--- includes/pages/admin_user.php | 118 ++++---- includes/pages/guest_login.php | 4 +- includes/sys_auth.php | 43 --- includes/view/User_view.php | 7 +- src/Helpers/Authenticator.php | 71 ++--- src/Helpers/AuthenticatorServiceProvider.php | 1 + src/Models/Group.php | 47 +++ src/Models/Privilege.php | 40 +++ src/Models/User/User.php | 95 ++++-- tests/Unit/Controllers/HomeControllerTest.php | 3 +- .../AuthenticatorServiceProviderTest.php | 9 +- tests/Unit/Helpers/AuthenticatorTest.php | 42 ++- tests/Unit/Models/GroupTest.php | 59 ++++ tests/Unit/Models/PrivilegeTest.php | 37 +++ tests/Unit/Models/User/UserTest.php | 207 +++++++++---- 24 files changed, 923 insertions(+), 331 deletions(-) create mode 100644 db/factories/GroupFactory.php create mode 100644 db/factories/PrivilegeFactory.php create mode 100644 db/migrations/2022_10_23_000000_create_privileges_and_groups_related_tables.php delete mode 100644 includes/model/UserGroups_model.php delete mode 100644 includes/sys_auth.php create mode 100644 src/Models/Group.php create mode 100644 src/Models/Privilege.php create mode 100644 tests/Unit/Models/GroupTest.php create mode 100644 tests/Unit/Models/PrivilegeTest.php diff --git a/db/factories/GroupFactory.php b/db/factories/GroupFactory.php new file mode 100644 index 00000000..54c8d08f --- /dev/null +++ b/db/factories/GroupFactory.php @@ -0,0 +1,22 @@ + $this->faker->word(), + ]; + } +} diff --git a/db/factories/PrivilegeFactory.php b/db/factories/PrivilegeFactory.php new file mode 100644 index 00000000..12fee080 --- /dev/null +++ b/db/factories/PrivilegeFactory.php @@ -0,0 +1,23 @@ + $this->faker->word(), + 'description' => $this->faker->text(), + ]; + } +} diff --git a/db/migrations/2022_10_23_000000_create_privileges_and_groups_related_tables.php b/db/migrations/2022_10_23_000000_create_privileges_and_groups_related_tables.php new file mode 100644 index 00000000..38e993cb --- /dev/null +++ b/db/migrations/2022_10_23_000000_create_privileges_and_groups_related_tables.php @@ -0,0 +1,274 @@ +schema->hasTable('Privileges'); + + if ($hasPrevious) { + // Rename because some DBMS handle identifiers case-insensitive + $this->schema->rename('Groups', 'groups_old'); + $this->schema->rename('Privileges', 'privileges_old'); + } + + $this->createNew(); + + if ($hasPrevious) { + $this->copyOldToNew(); + + $this->changeReferences( + 'groups_old', + 'UID', + 'groups', + 'id' + ); + $this->changeReferences( + 'privileges_old', + 'id', + 'privileges', + 'id' + ); + $this->changeReferences( + 'UserGroups', + 'id', + 'users_groups', + 'id' + ); + $this->changeReferences( + 'GroupPrivileges', + 'id', + 'group_privileges', + 'id' + ); + + $this->schema->drop('UserGroups'); + $this->schema->drop('GroupPrivileges'); + $this->schema->drop('groups_old'); + $this->schema->drop('privileges_old'); + } + } + + /** + * Recreates the previous table, copies the data and drops the new one. + */ + public function down(): void + { + // Rename because some DBMS handle identifiers case-insensitive + $this->schema->rename('groups', 'groups_new'); + $this->schema->rename('privileges', 'privileges_new'); + + $this->createOldTable(); + $this->copyNewToOld(); + + $this->changeReferences( + 'groups_new', + 'id', + 'Groups', + 'UID', + 'integer' + ); + $this->changeReferences( + 'privileges_new', + 'id', + 'Privileges', + 'id' + ); + $this->changeReferences( + 'users_groups', + 'id', + 'UserGroups', + 'id' + ); + $this->changeReferences( + 'group_privileges', + 'id', + 'GroupPrivileges', + 'id' + ); + + $this->schema->drop('users_groups'); + $this->schema->drop('group_privileges'); + $this->schema->drop('groups_new'); + $this->schema->drop('privileges_new'); + } + + /** + * @return void + */ + protected function createNew(): void + { + $this->schema->create('groups', function (Blueprint $table) { + $table->increments('id'); + $table->string('name', 35)->unique(); + }); + + $this->schema->create('privileges', function (Blueprint $table) { + $table->increments('id'); + $table->string('name', 128)->unique(); + $table->string('description', 1024); + }); + + $this->schema->create('users_groups', function (Blueprint $table) { + $table->increments('id'); + $this->referencesUser($table)->index(); + $this->references($table, 'groups')->index(); + }); + + $this->schema->create('group_privileges', function (Blueprint $table) { + $table->increments('id'); + $this->references($table, 'groups')->index(); + $this->references($table, 'privileges')->index(); + }); + } + + /** + * @return void + */ + protected function createOldTable(): void + { + $this->schema->create('Groups', function (Blueprint $table) { + $table->string('Name', 35); + $table->integer('UID')->primary(); + }); + + $this->schema->create('Privileges', function (Blueprint $table) { + $table->increments('id'); + $table->string('name', 128)->unique(); + $table->string('desc', 1024); + }); + + $this->schema->create('UserGroups', function (Blueprint $table) { + $table->increments('id'); + $this->references($table, 'users', 'uid'); + $this->references($table, 'Groups', 'group_id', 'UID', false, 'integer')->index(); + $table->index(['uid', 'group_id']); + }); + + $this->schema->create('GroupPrivileges', function (Blueprint $table) { + $table->increments('id'); + $this->references($table, 'Groups', 'group_id', 'UID', false, 'integer'); + $this->references($table, 'Privileges', 'privilege_id')->index(); + $table->index(['group_id', 'privilege_id']); + }); + } + + /** + * @return void + */ + protected function copyOldToNew(): void + { + $connection = $this->schema->getConnection(); + + /** @var stdClass[] $records */ + $records = $connection + ->table('groups_old') + ->get(); + foreach ($records as $record) { + $connection->table('groups')->insert([ + 'id' => $record->UID, + 'name' => $record->Name, + ]); + } + + $records = $connection + ->table('privileges_old') + ->get(); + foreach ($records as $record) { + $connection->table('privileges')->insert([ + 'id' => $record->id, + 'name' => $record->name, + 'description' => $record->desc, + ]); + } + + $records = $connection + ->table('UserGroups') + ->get(); + foreach ($records as $record) { + $connection->table('users_groups')->insert([ + 'id' => $record->id, + 'user_id' => $record->uid, + 'group_id' => $record->group_id, + ]); + } + + $records = $connection + ->table('GroupPrivileges') + ->get(); + foreach ($records as $record) { + $connection->table('group_privileges')->insert([ + 'id' => $record->id, + 'group_id' => $record->group_id, + 'privilege_id' => $record->privilege_id, + ]); + } + } + + /** + * @return void + */ + protected function copyNewToOld(): void + { + $connection = $this->schema->getConnection(); + + /** @var Collection|stdClass[] $records */ + $records = $connection + ->table('groups_new') + ->get(); + foreach ($records as $record) { + $connection->table('Groups')->insert([ + 'Name' => $record->name, + 'UID' => $record->id, + ]); + } + + $records = $connection + ->table('privileges_new') + ->get(); + foreach ($records as $record) { + $connection->table('Privileges')->insert([ + 'id' => $record->id, + 'name' => $record->name, + 'desc' => $record->description, + ]); + } + + $records = $connection + ->table('users_groups') + ->get(); + foreach ($records as $record) { + $connection->table('UserGroups')->insert([ + 'id' => $record->id, + 'uid' => $record->user_id, + 'group_id' => $record->group_id, + ]); + } + + $records = $connection + ->table('group_privileges') + ->get(); + foreach ($records as $record) { + $connection->table('GroupPrivileges')->insert([ + 'id' => $record->id, + 'group_id' => $record->group_id, + 'privilege_id' => $record->privilege_id, + ]); + } + } +} diff --git a/db/migrations/Reference.php b/db/migrations/Reference.php index 7d08ab02..d0c695e8 100644 --- a/db/migrations/Reference.php +++ b/db/migrations/Reference.php @@ -11,47 +11,56 @@ trait Reference /** * @param Blueprint $table * @param bool $setPrimary + * @return ColumnDefinition */ - protected function referencesUser(Blueprint $table, bool $setPrimary = false) + protected function referencesUser(Blueprint $table, bool $setPrimary = false): ColumnDefinition { - $this->references($table, 'users', null, $setPrimary); + return $this->references($table, 'users', null, null, $setPrimary); } /** * @param Blueprint $table * @param string $targetTable * @param string|null $fromColumn + * @param string|null $targetColumn * @param bool $setPrimary - * + * @param string $type * @return ColumnDefinition */ protected function references( Blueprint $table, string $targetTable, ?string $fromColumn = null, - bool $setPrimary = false + ?string $targetColumn = null, + bool $setPrimary = false, + string $type = 'unsignedInteger' ): ColumnDefinition { $fromColumn = $fromColumn ?? Str::singular($targetTable) . '_id'; - $col = $table->unsignedInteger($fromColumn); + $col = $table->{$type}($fromColumn); if ($setPrimary) { $table->primary($fromColumn); } - $this->addReference($table, $fromColumn, $targetTable); + $this->addReference($table, $fromColumn, $targetTable, $targetColumn ?: 'id'); return $col; } /** - * @param Blueprint $table - * @param string $fromColumn - * @param string $targetTable + * @param Blueprint $table + * @param string $fromColumn + * @param string $targetTable + * @param string|null $targetColumn */ - protected function addReference(Blueprint $table, string $fromColumn, string $targetTable) - { + protected function addReference( + Blueprint $table, + string $fromColumn, + string $targetTable, + ?string $targetColumn = null + ) { $table->foreign($fromColumn) - ->references('id')->on($targetTable) + ->references($targetColumn ?: 'id')->on($targetTable) ->onUpdate('cascade') ->onDelete('cascade'); } diff --git a/includes/controller/users_controller.php b/includes/controller/users_controller.php index b9e9f2a8..ba172a9c 100644 --- a/includes/controller/users_controller.php +++ b/includes/controller/users_controller.php @@ -251,7 +251,7 @@ function user_controller() auth()->can('admin_user'), User_is_freeloader($user_source), User_angeltypes($user_source->id), - User_groups($user_source->id), + $user_source->groups, $shifts, $user->id == $user_source->id, $tshirt_score, diff --git a/includes/includes.php b/includes/includes.php index 65be2323..87c2e5de 100644 --- a/includes/includes.php +++ b/includes/includes.php @@ -5,7 +5,6 @@ */ $includeFiles = [ - __DIR__ . '/../includes/sys_auth.php', __DIR__ . '/../includes/sys_form.php', __DIR__ . '/../includes/sys_log.php', __DIR__ . '/../includes/sys_menu.php', @@ -22,7 +21,6 @@ $includeFiles = [ __DIR__ . '/../includes/model/ShiftTypes_model.php', __DIR__ . '/../includes/model/Stats.php', __DIR__ . '/../includes/model/UserAngelTypes_model.php', - __DIR__ . '/../includes/model/UserGroups_model.php', __DIR__ . '/../includes/model/User_model.php', __DIR__ . '/../includes/model/UserWorkLog_model.php', __DIR__ . '/../includes/model/ValidationResult.php', diff --git a/includes/model/UserAngelTypes_model.php b/includes/model/UserAngelTypes_model.php index 90871ff1..3d668c62 100644 --- a/includes/model/UserAngelTypes_model.php +++ b/includes/model/UserAngelTypes_model.php @@ -78,7 +78,7 @@ function User_is_AngelType_supporter($user, $angeltype) return false; } - $privileges = privileges_for_user($user->id); + $privileges = $user->privileges->pluck('name')->toArray(); return (count(Db::select( ' diff --git a/includes/model/UserGroups_model.php b/includes/model/UserGroups_model.php deleted file mode 100644 index 5169fc74..00000000 --- a/includes/model/UserGroups_model.php +++ /dev/null @@ -1,23 +0,0 @@ -orderBy('name')->get(); if (!$request->has('action')) { $groups_table = []; foreach ($groups as $group) { - $privileges = Db::select(' - SELECT `name` - FROM `GroupPrivileges` - JOIN `Privileges` ON (`GroupPrivileges`.`privilege_id` = `Privileges`.`id`) - WHERE `group_id`=? - ORDER BY `name` - ', [$group['UID']]); + /** @var Privilege[]|Collection $privileges */ + $privileges = $group->privileges()->orderBy('name')->get(); $privileges_html = []; foreach ($privileges as $privilege) { @@ -36,12 +35,12 @@ function admin_groups() } $groups_table[] = [ - 'name' => $group['Name'], + 'name' => $group->name, 'privileges' => join(', ', $privileges_html), 'actions' => button( page_link_to( 'admin_groups', - ['action' => 'edit', 'id' => $group['UID']] + ['action' => 'edit', 'id' => $group->id] ), __('edit'), 'btn-sm' @@ -65,34 +64,26 @@ function admin_groups() return error('Incomplete call, missing Groups ID.', true); } - $group = Db::select('SELECT * FROM `Groups` WHERE `UID`=? LIMIT 1', [$group_id]); + /** @var Group|null $group */ + $group = Group::find($group_id); if (!empty($group)) { - $privileges = Db::select(' - SELECT `Privileges`.*, `GroupPrivileges`.`group_id` - FROM `Privileges` - LEFT OUTER JOIN `GroupPrivileges` - ON ( - `Privileges`.`id` = `GroupPrivileges`.`privilege_id` - AND `GroupPrivileges`.`group_id`=? - ) - ORDER BY `Privileges`.`name` - ', [$group_id]); + $privileges = groupPrivilegesWithSelected($group); $privileges_form = []; foreach ($privileges as $privilege) { $privileges_form[] = form_checkbox( 'privileges[]', - $privilege['desc'] . ' (' . $privilege['name'] . ')', - $privilege['group_id'] != '', - $privilege['id'], - 'privilege-' . $privilege['name'] + $privilege->description . ' (' . $privilege->name . ')', + $privilege->selected != '', + $privilege->id, + 'privilege-' . $privilege->name ); } $privileges_form[] = form_submit('submit', __('Save')); - $html .= page_with_title(__('Edit group'), [ + $html .= page_with_title(__('Edit group') . ' ' . $group->name, [ form( $privileges_form, - page_link_to('admin_groups', ['action' => 'save', 'id' => $group_id]) + page_link_to('admin_groups', ['action' => 'save', 'id' => $group->id]) ) ]); } else { @@ -110,29 +101,21 @@ function admin_groups() return error('Incomplete call, missing Groups ID.', true); } - $group = Db::selectOne('SELECT * FROM `Groups` WHERE `UID`=? LIMIT 1', [$group_id]); + /** @var Group|null $group */ + $group = Group::find($group_id); $privileges = $request->request->all('privileges'); if (!empty($group)) { - Db::delete('DELETE FROM `GroupPrivileges` WHERE `group_id`=?', [$group_id]); + $group->privileges()->detach(); $privilege_names = []; foreach ($privileges as $privilege) { - $privilege = (int)$privilege; + $privilege = Privilege::find($privilege); if ($privilege) { - $group_privileges_source = Db::selectOne( - 'SELECT `name` FROM `Privileges` WHERE `id`=? LIMIT 1', - [$privilege] - ); - if (!empty($group_privileges_source)) { - Db::insert( - 'INSERT INTO `GroupPrivileges` (`group_id`, `privilege_id`) VALUES (?, ?)', - [$group_id, $privilege] - ); - $privilege_names[] = $group_privileges_source['name']; - } + $group->privileges()->attach($privilege); + $privilege_names[] = $privilege->name; } } engelsystem_log( - 'Group privileges of group ' . $group['Name'] + 'Group privileges of group ' . $group->name . ' edited: ' . join(', ', $privilege_names) ); throw_redirect(page_link_to('admin_groups')); @@ -144,3 +127,24 @@ function admin_groups() } return $html; } + +/** + * @param Group $group + * @return Collection|Privilege[] + */ +function groupPrivilegesWithSelected(Group $group): Collection +{ + return Privilege::query() + ->join('group_privileges', function ($query) use ($group) { + /** @var JoinClause $query */ + $query + ->where('privileges.id', '=', $query->raw('group_privileges.privilege_id')) + ->where('group_privileges.group_id', $group->id) + ; + }, null, null, 'left outer') + ->orderBy('name') + ->get([ + 'privileges.*', + 'group_privileges.group_id as selected' + ]); +} diff --git a/includes/pages/admin_user.php b/includes/pages/admin_user.php index 9f3c2f6b..ed4addf0 100644 --- a/includes/pages/admin_user.php +++ b/includes/pages/admin_user.php @@ -1,7 +1,9 @@ '; - $my_highest_group = DB::selectOne( - 'SELECT group_id FROM `UserGroups` WHERE `uid`=? ORDER BY `group_id` DESC LIMIT 1', - [$user->id] - ); + /** @var Group $my_highest_group */ + $my_highest_group = $user->groups()->orderByDesc('id')->first(); if (!empty($my_highest_group)) { - $my_highest_group = $my_highest_group['group_id']; + $my_highest_group = $my_highest_group->id; } - $angel_highest_group = DB::selectOne( - 'SELECT group_id FROM `UserGroups` WHERE `uid`=? ORDER BY `group_id` DESC LIMIT 1', - [$user_id] - ); + $angel_highest_group = $user_source->groups()->orderByDesc('id')->first(); if (!empty($angel_highest_group)) { - $angel_highest_group = $angel_highest_group['group_id']; + $angel_highest_group = $angel_highest_group->id; } if ( @@ -152,26 +149,11 @@ function admin_user() $html .= form_csrf(); $html .= ''; - $groups = DB::select( - ' - SELECT * - FROM `Groups` - LEFT OUTER JOIN `UserGroups` ON ( - `UserGroups`.`group_id` = `Groups`.`UID` - AND `UserGroups`.`uid` = ? - ) - WHERE `Groups`.`UID` <= ? - ORDER BY `Groups`.`Name` - ', - [ - $user_id, - $my_highest_group, - ] - ); + $groups = changeableGroups($my_highest_group, $user_id); foreach ($groups as $group) { - $html .= ''; + $html .= ''; } $html .= '
' . $group['Name'] . '
selected ? ' checked="checked"' : '') + . ' />' . $group->name . '

'; @@ -190,44 +172,26 @@ function admin_user() } else { switch ($request->input('action')) { case 'save_groups': - if ($user_id != $user->id || auth()->can('admin_groups')) { - $my_highest_group = DB::selectOne( - 'SELECT * FROM `UserGroups` WHERE `uid`=? ORDER BY `group_id` DESC LIMIT 1', - [$user->id] - ); - $angel_highest_group = DB::selectOne( - 'SELECT * FROM `UserGroups` WHERE `uid`=? ORDER BY `group_id` DESC LIMIT 1', - [$user_id] - ); + $angel = User::findOrFail($user_id); + if ($angel->id != $user->id || auth()->can('admin_groups')) { + /** @var Group $my_highest_group */ + $my_highest_group = $user->groups()->orderByDesc('id')->first(); + /** @var Group $angel_highest_group */ + $angel_highest_group = $angel->groups()->orderByDesc('id')->first(); if ( $my_highest_group && ( empty($angel_highest_group) - || ($my_highest_group['group_id'] >= $angel_highest_group['group_id']) + || ($my_highest_group->id >= $angel_highest_group->id) ) ) { - $groups_source = DB::select( - ' - SELECT * - FROM `Groups` - LEFT OUTER JOIN `UserGroups` ON ( - `UserGroups`.`group_id` = `Groups`.`UID` - AND `UserGroups`.`uid` = ? - ) - WHERE `Groups`.`UID` <= ? - ORDER BY `Groups`.`Name` - ', - [ - $user_id, - $my_highest_group['group_id'], - ] - ); + $groups_source = changeableGroups($my_highest_group->id, $angel->id); $groups = []; - $grouplist = []; + $groupList = []; foreach ($groups_source as $group) { - $groups[$group['UID']] = $group; - $grouplist[] = $group['UID']; + $groups[$group->id] = $group; + $groupList[] = $group->id; } $groupsRequest = $request->input('groups'); @@ -235,20 +199,17 @@ function admin_user() $groupsRequest = []; } - DB::delete('DELETE FROM `UserGroups` WHERE `uid`=?', [$user_id]); + $angel->groups()->detach(); $user_groups_info = []; foreach ($groupsRequest as $group) { - if (in_array($group, $grouplist)) { - DB::insert( - 'INSERT INTO `UserGroups` (`uid`, `group_id`) VALUES (?, ?)', - [$user_id, $group] - ); - $user_groups_info[] = $groups[$group]['Name']; + if (in_array($group, $groupList)) { + $group = $groups[$group]; + $angel->groups()->attach($group); + $user_groups_info[] = $group->name; } } - $user_source = User::find($user_id); engelsystem_log( - 'Set groups of ' . User_Nick_render($user_source, true) . ' to: ' + 'Set groups of ' . User_Nick_render($angel, true) . ' to: ' . join(', ', $user_groups_info) ); $html .= success('Benutzergruppen gespeichert.', true); @@ -321,3 +282,24 @@ function admin_user() $html ]); } + +/** + * @param $myHighestGroup + * @param $angelId + * @return Collection|Group[] + */ +function changeableGroups($myHighestGroup, $angelId): Collection +{ + return Group::query() + ->where('groups.id', '<=', $myHighestGroup) + ->join('users_groups', function ($query) use ($angelId) { + /** @var JoinClause $query */ + $query->where('users_groups.group_id', '=', $query->raw('groups.id')) + ->where('users_groups.user_id', $angelId); + }, null, null, 'left outer') + ->orderBy('name') + ->get([ + 'groups.*', + 'users_groups.group_id as selected' + ]); +} diff --git a/includes/pages/guest_login.php b/includes/pages/guest_login.php index 1898e599..ec2421f8 100644 --- a/includes/pages/guest_login.php +++ b/includes/pages/guest_login.php @@ -4,6 +4,7 @@ use Carbon\Carbon; use Engelsystem\Database\Database; use Engelsystem\Database\Db; use Engelsystem\Events\Listener\OAuth2; +use Engelsystem\Models\Group; use Engelsystem\Models\OAuth; use Engelsystem\Models\User\Contact; use Engelsystem\Models\User\PersonalData; @@ -299,7 +300,8 @@ function guest_register() } // Assign user-group and set password - Db::insert('INSERT INTO `UserGroups` (`uid`, `group_id`) VALUES (?, 20)', [$user->id]); + $defaultGroup = Group::find(auth()->getDefaultRole()); + $user->groups()->attach($defaultGroup); if ($enable_password) { auth()->setPassword($user, $request->postData('password')); } diff --git a/includes/sys_auth.php b/includes/sys_auth.php deleted file mode 100644 index 646cf06a..00000000 --- a/includes/sys_auth.php +++ /dev/null @@ -1,43 +0,0 @@ -name); } return div('col-md-2', [ diff --git a/src/Helpers/Authenticator.php b/src/Helpers/Authenticator.php index e4d2667b..125a3391 100644 --- a/src/Helpers/Authenticator.php +++ b/src/Helpers/Authenticator.php @@ -3,6 +3,7 @@ namespace Engelsystem\Helpers; use Carbon\Carbon; +use Engelsystem\Models\Group; use Engelsystem\Models\User\User; use Engelsystem\Models\User\User as UserRepository; use Psr\Http\Message\ServerRequestInterface; @@ -10,26 +11,29 @@ use Symfony\Component\HttpFoundation\Session\Session; class Authenticator { - /** @var User */ - protected $user = null; + /** @var User|null */ + protected ?User $user = null; /** @var ServerRequestInterface */ - protected $request; + protected ServerRequestInterface $request; /** @var Session */ - protected $session; + protected Session $session; /** @var UserRepository */ - protected $userRepository; + protected UserRepository $userRepository; /** @var string[] */ - protected $permissions; + protected array $permissions = []; /** @var int|string|null */ protected $passwordAlgorithm = PASSWORD_DEFAULT; /** @var int */ - protected $guestRole = 10; + protected int $defaultRole = 20; + + /** @var int */ + protected int $guestRole = 10; /** * @param ServerRequestInterface $request @@ -48,7 +52,7 @@ class Authenticator * * @return User|null */ - public function user() + public function user(): ?User { if ($this->user) { return $this->user; @@ -77,7 +81,7 @@ class Authenticator * @param string $parameter * @return User|null */ - public function apiUser($parameter = 'api_key') + public function apiUser(string $parameter = 'api_key'): ?User { if ($this->user) { return $this->user; @@ -88,6 +92,7 @@ class Authenticator return null; } + /** @var User|null $user */ $user = $this ->userRepository ->whereApiKey($params[$parameter]) @@ -113,7 +118,7 @@ class Authenticator $user = $this->user(); if ($user) { - $this->permissions = $this->getPermissionsByUser($user); + $this->permissions = $user->privileges->pluck('name')->toArray(); $user->last_login_at = new Carbon(); $user->save(); @@ -122,7 +127,9 @@ class Authenticator } if (empty($this->permissions)) { - $this->permissions = $this->getPermissionsByGroup($this->guestRole); + /** @var Group $group */ + $group = Group::find($this->guestRole); + $this->permissions = $group->privileges->pluck('name')->toArray(); } } @@ -140,7 +147,7 @@ class Authenticator * @param string $password * @return User|null */ - public function authenticate(string $login, string $password) + public function authenticate(string $login, string $password): ?User { /** @var User $user */ $user = $this->userRepository->whereName($login)->first(); @@ -164,7 +171,7 @@ class Authenticator * @param string $password * @return bool */ - public function verifyPassword(User $user, string $password) + public function verifyPassword(User $user, string $password): bool { if (!password_verify($password, $user->password)) { return false; @@ -206,7 +213,23 @@ class Authenticator /** * @return int */ - public function getGuestRole() + public function getDefaultRole(): int + { + return $this->defaultRole; + } + + /** + * @param int $defaultRole + */ + public function setDefaultRole(int $defaultRole) + { + $this->defaultRole = $defaultRole; + } + + /** + * @return int + */ + public function getGuestRole(): int { return $this->guestRole; } @@ -218,24 +241,4 @@ class Authenticator { $this->guestRole = $guestRole; } - - /** - * @param User $user - * @return array - * @codeCoverageIgnore - */ - protected function getPermissionsByUser($user) - { - return privileges_for_user($user->id); - } - - /** - * @param int $groupId - * @return array - * @codeCoverageIgnore - */ - protected function getPermissionsByGroup(int $groupId) - { - return privileges_for_group($groupId); - } } diff --git a/src/Helpers/AuthenticatorServiceProvider.php b/src/Helpers/AuthenticatorServiceProvider.php index 3534618d..c3f58fff 100644 --- a/src/Helpers/AuthenticatorServiceProvider.php +++ b/src/Helpers/AuthenticatorServiceProvider.php @@ -15,6 +15,7 @@ class AuthenticatorServiceProvider extends ServiceProvider $authenticator = $this->app->make(Authenticator::class); $authenticator->setPasswordAlgorithm($config->get('password_algorithm')); $authenticator->setGuestRole($config->get('auth_guest_role', $authenticator->getGuestRole())); + $authenticator->setDefaultRole($config->get('auth_default_role', $authenticator->getDefaultRole())); $this->app->instance(Authenticator::class, $authenticator); $this->app->instance('authenticator', $authenticator); diff --git a/src/Models/Group.php b/src/Models/Group.php new file mode 100644 index 00000000..bc237dff --- /dev/null +++ b/src/Models/Group.php @@ -0,0 +1,47 @@ +belongsToMany(Privilege::class, 'group_privileges'); + } + + /** + * @return BelongsToMany + */ + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'users_groups'); + } +} diff --git a/src/Models/Privilege.php b/src/Models/Privilege.php new file mode 100644 index 00000000..500e043a --- /dev/null +++ b/src/Models/Privilege.php @@ -0,0 +1,40 @@ +belongsToMany(Group::class, 'group_privileges'); + } +} diff --git a/src/Models/User/User.php b/src/Models/User/User.php index 2d06468d..11a052eb 100644 --- a/src/Models/User/User.php +++ b/src/Models/User/User.php @@ -4,43 +4,51 @@ namespace Engelsystem\Models\User; use Carbon\Carbon; use Engelsystem\Models\BaseModel; +use Engelsystem\Models\Group; use Engelsystem\Models\Message; use Engelsystem\Models\News; use Engelsystem\Models\NewsComment; use Engelsystem\Models\OAuth; +use Engelsystem\Models\Privilege; use Engelsystem\Models\Question; use Engelsystem\Models\Worklog; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Query\Builder as QueryBuilder; +use Illuminate\Support\Collection as SupportCollection; /** - * @property int $id - * @property string $name - * @property string $email - * @property string $password - * @property string $api_key - * @property Carbon|null $last_login_at - * @property Carbon $created_at - * @property Carbon $updated_at + * @property int $id + * @property string $name + * @property string $email + * @property string $password + * @property string $api_key + * @property Carbon|null $last_login_at + * @property Carbon $created_at + * @property Carbon $updated_at * - * @property-read QueryBuilder|Contact $contact - * @property-read QueryBuilder|License $license - * @property-read QueryBuilder|PersonalData $personalData - * @property-read QueryBuilder|Settings $settings - * @property-read QueryBuilder|State $state - * @property-read Collection|News[] $news - * @property-read Collection|NewsComment[] $newsComments - * @property-read Collection|OAuth[] $oauth - * @property-read Collection|Worklog[] $worklogs - * @property-read Collection|Worklog[] $worklogsCreated - * @property-read int|null $news_count - * @property-read int|null $news_comments_count - * @property-read int|null $oauth_count - * @property-read int|null $worklogs_count - * @property-read int|null $worklogs_created_count + * @property-read QueryBuilder|Contact $contact + * @property-read QueryBuilder|License $license + * @property-read QueryBuilder|PersonalData $personalData + * @property-read QueryBuilder|Settings $settings + * @property-read QueryBuilder|State $state + * + * @property-read Collection|Group[] $groups + * @property-read Collection|News[] $news + * @property-read Collection|NewsComment[] $newsComments + * @property-read Collection|OAuth[] $oauth + * @property-read SupportCollection|Privilege[] $privileges + * @property-read Collection|Worklog[] $worklogs + * @property-read Collection|Worklog[] $worklogsCreated + * @property-read Collection|Question[] $questionsAsked + * @property-read Collection|Question[] $questionsAnswered + * @property-read Collection|Message[] $messagesReceived + * @property-read Collection|Message[] $messagesSent + * @property-read Collection|Message[] $messages * * @method static QueryBuilder|User[] whereId($value) * @method static QueryBuilder|User[] whereName($value) @@ -50,12 +58,6 @@ use Illuminate\Database\Query\Builder as QueryBuilder; * @method static QueryBuilder|User[] whereLastLoginAt($value) * @method static QueryBuilder|User[] whereCreatedAt($value) * @method static QueryBuilder|User[] whereUpdatedAt($value) - * - * @property-read Collection|Question[] $questionsAsked - * @property-read Collection|Question[] $questionsAnswered - * @property-read Collection|Message[] $messagesReceived - * @property-read Collection|Message[] $messagesSent - * @property-read Collection|Message[] $messages */ class User extends BaseModel { @@ -94,6 +96,14 @@ class User extends BaseModel ->withDefault(); } + /** + * @return BelongsToMany + */ + public function groups(): BelongsToMany + { + return $this->belongsToMany(Group::class, 'users_groups'); + } + /** * @return HasOne */ @@ -104,6 +114,33 @@ class User extends BaseModel ->withDefault(); } + /** + * @return Builder + */ + public function privileges(): Builder + { + /** @var Builder $builder */ + $builder = Privilege::query() + ->whereIn('id', function ($query) { + /** @var QueryBuilder $query */ + $query->select('privilege_id') + ->from('group_privileges') + ->join('users_groups', 'users_groups.group_id', '=', 'group_privileges.group_id') + ->where('users_groups.user_id', '=', $this->id) + ->distinct(); + }); + + return $builder; + } + + /** + * @return SupportCollection + */ + public function getPrivilegesAttribute(): SupportCollection + { + return $this->privileges()->get(); + } + /** * @return HasOne */ diff --git a/tests/Unit/Controllers/HomeControllerTest.php b/tests/Unit/Controllers/HomeControllerTest.php index b5205155..53778205 100644 --- a/tests/Unit/Controllers/HomeControllerTest.php +++ b/tests/Unit/Controllers/HomeControllerTest.php @@ -7,6 +7,7 @@ use Engelsystem\Controllers\HomeController; use Engelsystem\Helpers\Authenticator; use Engelsystem\Http\Redirector; use Engelsystem\Http\Response; +use Engelsystem\Models\User\User; use Engelsystem\Test\Unit\TestCase; use PHPUnit\Framework\MockObject\MockObject; @@ -21,7 +22,7 @@ class HomeControllerTest extends TestCase $config = new Config(['home_site' => '/foo']); /** @var Authenticator|MockObject $auth */ $auth = $this->createMock(Authenticator::class); - $this->setExpects($auth, 'user', null, true); + $this->setExpects($auth, 'user', null, new User()); /** @var Redirector|MockObject $redirect */ $redirect = $this->createMock(Redirector::class); $this->setExpects($redirect, 'to', ['/foo'], new Response()); diff --git a/tests/Unit/Helpers/AuthenticatorServiceProviderTest.php b/tests/Unit/Helpers/AuthenticatorServiceProviderTest.php index 175d720e..bbf80b38 100644 --- a/tests/Unit/Helpers/AuthenticatorServiceProviderTest.php +++ b/tests/Unit/Helpers/AuthenticatorServiceProviderTest.php @@ -20,9 +20,11 @@ class AuthenticatorServiceProviderTest extends ServiceProviderTest $app = new Application(); $app->bind(ServerRequestInterface::class, Request::class); - $config = new Config(); - $config->set('password_algorithm', PASSWORD_DEFAULT); - $config->set('auth_guest_role', 42); + $config = new Config([ + 'password_algorithm' => PASSWORD_DEFAULT, + 'auth_guest_role' => 42, + 'auth_default_role' => 1337, + ]); $app->instance('config', $config); $serviceProvider = new AuthenticatorServiceProvider($app); @@ -36,5 +38,6 @@ class AuthenticatorServiceProviderTest extends ServiceProviderTest $auth = $app->get(Authenticator::class); $this->assertEquals(PASSWORD_DEFAULT, $auth->getPasswordAlgorithm()); $this->assertEquals(42, $auth->getGuestRole()); + $this->assertEquals(1337, $auth->getDefaultRole()); } } diff --git a/tests/Unit/Helpers/AuthenticatorTest.php b/tests/Unit/Helpers/AuthenticatorTest.php index 3b1204bc..5bf681cf 100644 --- a/tests/Unit/Helpers/AuthenticatorTest.php +++ b/tests/Unit/Helpers/AuthenticatorTest.php @@ -3,6 +3,8 @@ namespace Engelsystem\Test\Unit\Helpers; use Engelsystem\Helpers\Authenticator; +use Engelsystem\Models\Group; +use Engelsystem\Models\Privilege; use Engelsystem\Models\User\User; use Engelsystem\Test\Unit\HasDatabase; use Engelsystem\Test\Unit\Helpers\Stub\UserModelImplementation; @@ -108,14 +110,23 @@ class AuthenticatorTest extends ServiceProviderTest */ public function testCan() { + $this->initDatabase(); + /** @var ServerRequestInterface|MockObject $request */ $request = $this->getMockForAbstractClass(ServerRequestInterface::class); /** @var Session|MockObject $session */ $session = $this->createMock(Session::class); /** @var UserModelImplementation|MockObject $userRepository */ $userRepository = new UserModelImplementation(); - /** @var User|MockObject $user */ - $user = $this->createMock(User::class); + /** @var User $user */ + $user = User::factory()->create(); + /** @var Group $group */ + $group = Group::factory()->create(); + /** @var Privilege $privilege */ + $privilege = Privilege::factory()->create(['name' => 'bar']); + + $user->groups()->attach($group); + $group->privileges()->attach($privilege); $session->expects($this->once()) ->method('get') @@ -128,20 +139,14 @@ class AuthenticatorTest extends ServiceProviderTest /** @var Authenticator|MockObject $auth */ $auth = $this->getMockBuilder(Authenticator::class) ->setConstructorArgs([$request, $session, $userRepository]) - ->onlyMethods(['getPermissionsByGroup', 'getPermissionsByUser', 'user']) + ->onlyMethods(['user']) ->getMock(); - $auth->expects($this->exactly(1)) - ->method('getPermissionsByGroup') - ->with(10) - ->willReturn([]); - $auth->expects($this->exactly(1)) - ->method('getPermissionsByUser') - ->with($user) - ->willReturn(['bar']); $auth->expects($this->exactly(2)) ->method('user') ->willReturnOnConsecutiveCalls(null, $user); + Group::factory()->create(['id' => $auth->getGuestRole()]); + // No user, no permissions $this->assertFalse($auth->can('foo')); @@ -245,6 +250,18 @@ class AuthenticatorTest extends ServiceProviderTest $this->assertEquals(PASSWORD_ARGON2I, $auth->getPasswordAlgorithm()); } + /** + * @covers \Engelsystem\Helpers\Authenticator::setDefaultRole + * @covers \Engelsystem\Helpers\Authenticator::getDefaultRole + */ + public function testDefaultRole() + { + $auth = $this->getAuthenticator(); + + $auth->setDefaultRole(1337); + $this->assertEquals(1337, $auth->getDefaultRole()); + } + /** * @covers \Engelsystem\Helpers\Authenticator::setGuestRole * @covers \Engelsystem\Helpers\Authenticator::getGuestRole @@ -262,8 +279,7 @@ class AuthenticatorTest extends ServiceProviderTest */ protected function getAuthenticator() { - return new class extends Authenticator - { + return new class extends Authenticator { /** @noinspection PhpMissingParentConstructorInspection */ public function __construct() { diff --git a/tests/Unit/Models/GroupTest.php b/tests/Unit/Models/GroupTest.php new file mode 100644 index 00000000..78d92108 --- /dev/null +++ b/tests/Unit/Models/GroupTest.php @@ -0,0 +1,59 @@ +create(); + /** @var Privilege $privilege2 */ + $privilege2 = Privilege::factory()->create(); + + $model = new Group(); + $model->name = 'Some Group'; + $model->save(); + + $model->privileges()->attach($privilege1); + $model->privileges()->attach($privilege2); + + /** @var Group $savedModel */ + $savedModel = Group::first(); + $this->assertEquals('Some Group', $savedModel->name); + $this->assertEquals($privilege1->name, $savedModel->privileges[0]->name); + $this->assertEquals($privilege2->name, $savedModel->privileges[1]->name); + } + + /** + * @covers \Engelsystem\Models\Group::users + */ + public function testUsers(): void + { + /** @var User $user1 */ + $user1 = User::factory()->create(); + /** @var User $user2 = */ + $user2 = User::factory()->create(); + + $model = new Group(); + $model->name = 'Some Group'; + $model->save(); + + $model->users()->attach($user1); + $model->users()->attach($user2); + + /** @var Group $savedModel */ + $savedModel = Group::first(); + $this->assertEquals($user1->name, $savedModel->users[0]->name); + $this->assertEquals($user2->name, $savedModel->users[1]->name); + } +} diff --git a/tests/Unit/Models/PrivilegeTest.php b/tests/Unit/Models/PrivilegeTest.php new file mode 100644 index 00000000..4805a1d5 --- /dev/null +++ b/tests/Unit/Models/PrivilegeTest.php @@ -0,0 +1,37 @@ +create(); + /** @var Group $group2 */ + $group2 = Group::factory()->create(); + + $model = new Privilege(); + $model->name = 'Some Privilege'; + $model->description = 'Some long description'; + $model->save(); + + $model->groups()->attach($group1); + $model->groups()->attach($group2); + + /** @var Privilege $savedModel */ + $savedModel = Privilege::first(); + $this->assertEquals('Some Privilege', $savedModel->name); + $this->assertEquals('Some long description', $savedModel->description); + $this->assertEquals($group1->name, $savedModel->groups[0]->name); + $this->assertEquals($group2->name, $savedModel->groups[1]->name); + } +} diff --git a/tests/Unit/Models/User/UserTest.php b/tests/Unit/Models/User/UserTest.php index 664b1332..6c255dd3 100644 --- a/tests/Unit/Models/User/UserTest.php +++ b/tests/Unit/Models/User/UserTest.php @@ -5,9 +5,11 @@ namespace Engelsystem\Test\Unit\Models\User; use Carbon\Carbon; use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; use Engelsystem\Models\BaseModel; +use Engelsystem\Models\Group; use Engelsystem\Models\News; use Engelsystem\Models\NewsComment; use Engelsystem\Models\OAuth; +use Engelsystem\Models\Privilege; use Engelsystem\Models\Question; use Engelsystem\Models\User\Contact; use Engelsystem\Models\User\HasUserModel; @@ -19,6 +21,7 @@ use Engelsystem\Models\User\User; use Engelsystem\Models\Worklog; use Engelsystem\Test\Unit\Models\ModelTest; use Exception; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Str; class UserTest extends ModelTest @@ -81,60 +84,6 @@ class UserTest extends ModelTest ]; } - /** - * @covers \Engelsystem\Models\User\User::contact - * @covers \Engelsystem\Models\User\User::license - * @covers \Engelsystem\Models\User\User::personalData - * @covers \Engelsystem\Models\User\User::settings - * @covers \Engelsystem\Models\User\User::state - * - * @dataProvider hasOneRelationsProvider - * - * @param string $class - * @param string $name - * @param array $data - * @throws Exception - */ - public function testHasOneRelations($class, $name, $data) - { - $user = new User($this->data); - $user->save(); - - /** @var HasUserModel $contact */ - $contact = new $class($data); - $contact->user() - ->associate($user) - ->save(); - - $this->assertArraySubset($data, (array)$user->{$name}->attributesToArray()); - } - - /** - * @covers \Engelsystem\Models\User\User::news() - * - * @dataProvider hasManyRelationsProvider - * - * @param string $class Class name of the related models - * @param string $name Name of the accessor for the related models - * @param array $modelData List of the related models - */ - public function testHasManyRelations(string $class, string $name, array $modelData): void - { - $user = new User($this->data); - $user->save(); - - $relatedModelIds = []; - - foreach ($modelData as $data) { - /** @var BaseModel $model */ - $model = $this->app->make($class); - $stored = $model->create($data + ['user_id' => $user->id]); - $relatedModelIds[] = $stored->id; - } - - $this->assertEquals($relatedModelIds, $user->{$name}->modelKeys()); - } - /** * @return array[] */ @@ -160,6 +109,156 @@ class UserTest extends ModelTest ]; } + /** + * @return array[] + */ + public function belongsToManyRelationsProvider(): array + { + return [ + 'groups' => [ + Group::class, + 'groups', + [ + [ + 'name' => 'Lorem', + ], + [ + 'name' => 'Ipsum', + ] + ] + ] + ]; + } + + /** + * @covers \Engelsystem\Models\User\User::contact + * @covers \Engelsystem\Models\User\User::license + * @covers \Engelsystem\Models\User\User::personalData + * @covers \Engelsystem\Models\User\User::settings + * @covers \Engelsystem\Models\User\User::state + * + * @dataProvider hasOneRelationsProvider + * + * @param string $class + * @param string $name + * @param array $data + * @throws Exception + */ + public function testHasOneRelations($class, $name, $data) + { + $user = new User($this->data); + $user->save(); + + /** @var HasUserModel $instance */ + $instance = new $class($data); + $instance->user() + ->associate($user) + ->save(); + + $this->assertArraySubset($data, (array)$user->{$name}->attributesToArray()); + } + + /** + * @covers \Engelsystem\Models\User\User::news() + * + * @dataProvider hasManyRelationsProvider + * + * @param string $class Class name of the related models + * @param string $name Name of the accessor for the related models + * @param array $modelData List of the related models + */ + public function testHasManyRelations(string $class, string $name, array $modelData): void + { + $user = new User($this->data); + $user->save(); + + $relatedModelIds = []; + + foreach ($modelData as $data) { + /** @var BaseModel $model */ + $model = $this->app->make($class); + $stored = $model->create($data + ['user_id' => $user->id]); + $relatedModelIds[] = $stored->id; + } + + $this->assertEquals($relatedModelIds, $user->{$name}->modelKeys()); + } + + + /** + * @covers \Engelsystem\Models\User\User::groups + * + * @dataProvider belongsToManyRelationsProvider + * + * @param string $class Class name of the related models + * @param string $name Name of the accessor for the related models + * @param array $modelData List of the related models + */ + public function testBelongsToManyRelations(string $class, string $name, array $modelData): void + { + $user = new User($this->data); + $user->save(); + + $relatedModelIds = []; + + foreach ($modelData as $data) { + /** @var BaseModel $model */ + $model = $this->app->make($class); + $stored = $model->create($data); + $stored->users()->attach($user); + $relatedModelIds[] = $stored->id; + } + + $this->assertEquals($relatedModelIds, $user->{$name}->modelKeys()); + } + + /** + * @covers \Engelsystem\Models\User\User::privileges + * @covers \Engelsystem\Models\User\User::getPrivilegesAttribute + */ + public function testPrivileges() + { + $user = new User($this->data); + $user->save(); + + /** @var Group $group1 */ + $group1 = Group::factory()->create(); + /** @var Group $group2 */ + $group2 = Group::factory()->create(); + /** @var Group $group3 */ + $group3 = Group::factory()->create(); + /** @var Privilege $privilege1 */ + $privilege1 = Privilege::factory()->create(); + /** @var Privilege $privilege2 */ + $privilege2 = Privilege::factory()->create(); + /** @var Privilege $privilege3 */ + $privilege3 = Privilege::factory()->create(); + /** @var Privilege $privilege4 */ + $privilege4 = Privilege::factory()->create(); + + $user->groups()->attach($group1); + $user->groups()->attach($group2); + + $group1->privileges()->attach($privilege1); + $group1->privileges()->attach($privilege2); + + $group2->privileges()->attach($privilege2); + $group2->privileges()->attach($privilege3); + + $group3->privileges()->attach($privilege3); + $group3->privileges()->attach($privilege4); + + /** @var User $createdUser */ + $createdUser = User::first(); + $this->assertInstanceOf(Builder::class, $createdUser->privileges()); + + $privileges = $createdUser->privileges->pluck('name'); + $this->assertCount(3, $privileges); + $this->assertContains($privilege1->name, $privileges); + $this->assertContains($privilege2->name, $privileges); + $this->assertContains($privilege3->name, $privileges); + } + /** * Tests that accessing the NewsComments of an User works. *