From 20b06b7b39d4369972d5d3880a3e6fcd204b6466 Mon Sep 17 00:00:00 2001 From: Boy132 Date: Tue, 20 Aug 2024 22:45:41 +0200 Subject: [PATCH 01/12] Fix variables on CreateServer page (#558) --- .../ServerResource/Pages/CreateServer.php | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/app/Filament/Resources/ServerResource/Pages/CreateServer.php b/app/Filament/Resources/ServerResource/Pages/CreateServer.php index 9f2de64b92..9c2c32da9b 100644 --- a/app/Filament/Resources/ServerResource/Pages/CreateServer.php +++ b/app/Filament/Resources/ServerResource/Pages/CreateServer.php @@ -6,7 +6,6 @@ use App\Models\Allocation; use App\Models\Egg; use App\Models\Node; -use App\Models\ServerVariable; use App\Models\User; use App\Services\Allocations\AssignmentService; use App\Services\Servers\RandomWordService; @@ -444,7 +443,7 @@ public function form(Form $form): Form $text = Forms\Components\TextInput::make('variable_value') ->hidden($this->shouldHideComponent(...)) - ->required(fn (ServerVariable $serverVariable) => $serverVariable->variable->getRequiredAttribute()) + ->required(fn (Forms\Get $get) => in_array('required', $get('rules'))) ->rules( fn (Forms\Get $get): Closure => function (string $attribute, $value, Closure $fail) use ($get) { $validator = Validator::make(['validatorkey' => $value], [ @@ -471,7 +470,7 @@ public function form(Form $form): Form ->live(onBlur: true) ->hintIcon('tabler-code') ->label(fn (Forms\Get $get) => $get('name')) - ->hintIconTooltip(fn (Forms\Get $get) => $get('rules')) + ->hintIconTooltip(fn (Forms\Get $get) => implode('|', $get('rules'))) ->prefix(fn (Forms\Get $get) => '{{' . $get('env_variable') . '}}') ->helperText(fn (Forms\Get $get) => empty($get('description')) ? '—' : $get('description')) ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) { @@ -806,9 +805,11 @@ protected function handleRecordCreation(array $data): Model return $service->handle($data); } - private function shouldHideComponent(ServerVariable $serverVariable, Forms\Components\Component $component): bool + private function shouldHideComponent(Forms\Get $get, Forms\Components\Component $component): bool { - $containsRuleIn = array_first($serverVariable->variable->rules, fn ($value) => str($value)->startsWith('in:'), false); + $containsRuleIn = collect($get('rules'))->reduce( + fn ($result, $value) => $result === true && !str($value)->startsWith('in:'), true + ); if ($component instanceof Forms\Components\Select) { return $containsRuleIn; @@ -821,9 +822,11 @@ private function shouldHideComponent(ServerVariable $serverVariable, Forms\Compo throw new \Exception('Component type not supported: ' . $component::class); } - private function getSelectOptionsFromRules(ServerVariable $serverVariable): array + private function getSelectOptionsFromRules(Forms\Get $get): array { - $inRule = array_first($serverVariable->variable->rules, fn ($value) => str($value)->startsWith('in:')); + $inRule = collect($get('rules'))->reduce( + fn ($result, $value) => str($value)->startsWith('in:') ? $value : $result, '' + ); return str($inRule) ->after('in:') From 05477c711fe7041ad71273179dc1a2a64bafbfb3 Mon Sep 17 00:00:00 2001 From: Boy132 Date: Thu, 22 Aug 2024 22:19:38 +0200 Subject: [PATCH 02/12] Create missing server variables on EditServer page (#560) * create missing server variables on editserver page * remove count check --- .../ServerResource/Pages/EditServer.php | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app/Filament/Resources/ServerResource/Pages/EditServer.php b/app/Filament/Resources/ServerResource/Pages/EditServer.php index 74cfe70180..31e682ab53 100644 --- a/app/Filament/Resources/ServerResource/Pages/EditServer.php +++ b/app/Filament/Resources/ServerResource/Pages/EditServer.php @@ -29,6 +29,7 @@ use Filament\Resources\Pages\EditRecord; use Illuminate\Support\Facades\Validator; use Closure; +use Illuminate\Database\Eloquent\Builder; use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction; class EditServer extends EditRecord @@ -473,7 +474,21 @@ public function form(Form $form): Form ->columnSpan(6), Forms\Components\Repeater::make('server_variables') - ->relationship('serverVariables') + ->relationship('serverVariables', function (Builder $query) { + /** @var Server $server */ + $server = $this->getRecord(); + + foreach ($server->variables as $variable) { + ServerVariable::query()->firstOrCreate([ + 'server_id' => $server->id, + 'variable_id' => $variable->id, + ], [ + 'variable_value' => $variable->server_value ?? '', + ]); + } + + return $query; + }) ->grid() ->mutateRelationshipDataBeforeSaveUsing(function (array &$data): array { foreach ($data as $key => $value) { From 818781ca66367e2bdb851ea4d7aa51468ffaaf8b Mon Sep 17 00:00:00 2001 From: Boy132 Date: Thu, 22 Aug 2024 22:19:56 +0200 Subject: [PATCH 03/12] Fix isViable for Nodes with "unlimited" resources (#559) --- app/Models/Node.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/Models/Node.php b/app/Models/Node.php index 473d3a4a2e..a322aa999e 100644 --- a/app/Models/Node.php +++ b/app/Models/Node.php @@ -244,21 +244,21 @@ public function allocations(): HasMany */ public function isViable(int $memory, int $disk, int $cpu): bool { - if ($this->memory_overallocate >= 0) { + if ($this->memory > 0 && $this->memory_overallocate >= 0) { $memoryLimit = $this->memory * (1 + ($this->memory_overallocate / 100)); if ($this->servers_sum_memory + $memory > $memoryLimit) { return false; } } - if ($this->disk_overallocate >= 0) { + if ($this->disk > 0 && $this->disk_overallocate >= 0) { $diskLimit = $this->disk * (1 + ($this->disk_overallocate / 100)); if ($this->servers_sum_disk + $disk > $diskLimit) { return false; } } - if ($this->cpu_overallocate >= 0) { + if ($this->cpu > 0 && $this->cpu_overallocate >= 0) { $cpuLimit = $this->cpu * (1 + ($this->cpu_overallocate / 100)); if ($this->servers_sum_cpu + $cpu > $cpuLimit) { return false; From 40810877e0f021b683baa29a0255634b3b2a2a46 Mon Sep 17 00:00:00 2001 From: Boy132 Date: Thu, 22 Aug 2024 22:20:11 +0200 Subject: [PATCH 04/12] Add redis connection check to installer (#556) --- .../Pages/Installer/PanelInstaller.php | 3 +++ .../Pages/Installer/Steps/DatabaseStep.php | 9 +++---- .../Pages/Installer/Steps/RedisStep.php | 27 ++++++++++++++++++- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/app/Filament/Pages/Installer/PanelInstaller.php b/app/Filament/Pages/Installer/PanelInstaller.php index b6aea2b098..160439086e 100644 --- a/app/Filament/Pages/Installer/PanelInstaller.php +++ b/app/Filament/Pages/Installer/PanelInstaller.php @@ -103,6 +103,9 @@ public function submit() $variables = array_get($inputs, 'env'); $this->writeToEnvironment($variables); + // Clear config cache + Artisan::call('config:clear'); + // Run migrations Artisan::call('migrate', [ '--force' => true, diff --git a/app/Filament/Pages/Installer/Steps/DatabaseStep.php b/app/Filament/Pages/Installer/Steps/DatabaseStep.php index 94a7952e2f..db9b36cead 100644 --- a/app/Filament/Pages/Installer/Steps/DatabaseStep.php +++ b/app/Filament/Pages/Installer/Steps/DatabaseStep.php @@ -7,7 +7,7 @@ use Filament\Forms\Get; use Filament\Notifications\Notification; use Filament\Support\Exceptions\Halt; -use Illuminate\Database\DatabaseManager; +use Illuminate\Support\Facades\DB; use PDOException; class DatabaseStep @@ -61,9 +61,6 @@ public static function make(): Step ->afterValidation(function (Get $get) { $driver = $get('env.DB_CONNECTION'); if ($driver !== 'sqlite') { - /** @var DatabaseManager $database */ - $database = app(DatabaseManager::class); - try { config()->set('database.connections._panel_install_test', [ 'driver' => $driver, @@ -77,7 +74,7 @@ public static function make(): Step 'strict' => true, ]); - $database->connection('_panel_install_test')->getPdo(); + DB::connection('_panel_install_test')->getPdo(); } catch (PDOException $exception) { Notification::make() ->title('Database connection failed') @@ -85,7 +82,7 @@ public static function make(): Step ->danger() ->send(); - $database->disconnect('_panel_install_test'); + DB::disconnect('_panel_install_test'); throw new Halt('Database connection failed'); } diff --git a/app/Filament/Pages/Installer/Steps/RedisStep.php b/app/Filament/Pages/Installer/Steps/RedisStep.php index 04a4b1b72f..2ab176f40c 100644 --- a/app/Filament/Pages/Installer/Steps/RedisStep.php +++ b/app/Filament/Pages/Installer/Steps/RedisStep.php @@ -2,8 +2,13 @@ namespace App\Filament\Pages\Installer\Steps; +use Exception; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Wizard\Step; +use Filament\Forms\Get; +use Filament\Notifications\Notification; +use Filament\Support\Exceptions\Halt; +use Illuminate\Support\Facades\Redis; class RedisStep { @@ -37,6 +42,26 @@ public static function make(): Step ->password() ->revealable() ->default(config('database.redis.default.password')), - ]); + ]) + ->afterValidation(function (Get $get) { + try { + config()->set('database.redis._panel_install_test', [ + 'host' => $get('env.REDIS_HOST'), + 'username' => $get('env.REDIS_USERNAME'), + 'password' => $get('env.REDIS_PASSWORD'), + 'port' => $get('env.REDIS_PORT'), + ]); + + Redis::connection('_panel_install_test')->command('ping'); + } catch (Exception $exception) { + Notification::make() + ->title('Redis connection failed') + ->body($exception->getMessage()) + ->danger() + ->send(); + + throw new Halt('Redis connection failed'); + } + }); } } From 58307c15a39a09d9b260bfa57606ac4a4f0a8843 Mon Sep 17 00:00:00 2001 From: notCharles Date: Sat, 24 Aug 2024 19:16:33 -0400 Subject: [PATCH 05/12] App Name AlphaNum Closes https://github.com/pelican-dev/panel/issues/562 --- app/Filament/Pages/Settings.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Filament/Pages/Settings.php b/app/Filament/Pages/Settings.php index ad9ea96bae..68574e0e9f 100644 --- a/app/Filament/Pages/Settings.php +++ b/app/Filament/Pages/Settings.php @@ -86,6 +86,7 @@ private function generalSettings(): array TextInput::make('APP_NAME') ->label('App Name') ->required() + ->alphaNum() ->default(env('APP_NAME', 'Pelican')), TextInput::make('APP_FAVICON') ->label('App Favicon') From e152efc5f9b7947510b327829591feda500f722d Mon Sep 17 00:00:00 2001 From: notCharles Date: Sat, 24 Aug 2024 21:05:43 -0400 Subject: [PATCH 06/12] Add toggle for starting server after install --- .../ServerResource/Pages/CreateServer.php | 48 ++++++++++++++----- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/app/Filament/Resources/ServerResource/Pages/CreateServer.php b/app/Filament/Resources/ServerResource/Pages/CreateServer.php index 9c2c32da9b..6e47053453 100644 --- a/app/Filament/Resources/ServerResource/Pages/CreateServer.php +++ b/app/Filament/Resources/ServerResource/Pages/CreateServer.php @@ -321,9 +321,9 @@ public function form(Form $form): Form ->completedIcon('tabler-check') ->columns([ 'default' => 1, - 'sm' => 2, - 'md' => 2, - 'lg' => 4, + 'sm' => 4, + 'md' => 4, + 'lg' => 6, ]) ->schema([ Forms\Components\Select::make('egg_id') @@ -333,7 +333,7 @@ public function form(Form $form): Form 'default' => 1, 'sm' => 2, 'md' => 2, - 'lg' => 3, + 'lg' => 4, ]) ->searchable() ->preload() @@ -390,29 +390,51 @@ public function form(Form $form): Form ->inline() ->required(), + Forms\Components\ToggleButtons::make('start_on_completion') + ->label('Start Server After Install?') + ->default(true) + ->required() + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 1, + ]) + ->options([ + true => 'Yes', + false => 'No', + ]) + ->colors([ + true => 'primary', + false => 'danger', + ]) + ->icons([ + true => 'tabler-code', + false => 'tabler-code-off', + ]) + ->inline(), + Forms\Components\Textarea::make('startup') ->hintIcon('tabler-code') ->label('Startup Command') ->hidden(fn (Forms\Get $get) => $get('egg_id') === null) ->required() ->live() - ->columnSpan([ - 'default' => 1, - 'sm' => 2, - 'md' => 2, - 'lg' => 4, - ]) ->rows(function ($state) { return str($state)->explode("\n")->reduce( fn (int $carry, $line) => $carry + floor(strlen($line) / 125), 1 ); - }), + }) + ->columnSpan([ + 'default' => 1, + 'sm' => 4, + 'md' => 4, + 'lg' => 6, + ]), Forms\Components\Hidden::make('environment')->default([]), - Forms\Components\Hidden::make('start_on_completion')->default(true), - Forms\Components\Section::make('Variables') ->icon('tabler-eggs') ->iconColor('primary') From d7b5966e1b780215e1ec3ec83ca3ee8ab43fbca8 Mon Sep 17 00:00:00 2001 From: Boy132 Date: Sun, 1 Sep 2024 17:42:15 +0200 Subject: [PATCH 07/12] Remove `required` from smtp username (#565) --- app/Filament/Pages/Settings.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app/Filament/Pages/Settings.php b/app/Filament/Pages/Settings.php index 68574e0e9f..08f95c84a3 100644 --- a/app/Filament/Pages/Settings.php +++ b/app/Filament/Pages/Settings.php @@ -275,7 +275,6 @@ private function mailSettings(): array ->default(env('MAIL_PORT', config('mail.mailers.smtp.port'))), TextInput::make('MAIL_USERNAME') ->label('Username') - ->required() ->default(env('MAIL_USERNAME', config('mail.mailers.smtp.username'))), TextInput::make('MAIL_PASSWORD') ->label('Password') From 49e93c13794bcd07151635623f16370a6b836dd8 Mon Sep 17 00:00:00 2001 From: notCharles Date: Fri, 6 Sep 2024 15:50:42 -0400 Subject: [PATCH 08/12] Fix Migration Fix rule migration reversal. --- .../migrations/2024_07_25_072050_convert_rules_to_array.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/migrations/2024_07_25_072050_convert_rules_to_array.php b/database/migrations/2024_07_25_072050_convert_rules_to_array.php index 8e6473a950..54eb7b1364 100644 --- a/database/migrations/2024_07_25_072050_convert_rules_to_array.php +++ b/database/migrations/2024_07_25_072050_convert_rules_to_array.php @@ -26,7 +26,7 @@ public function up(): void */ public function down(): void { - Schema::table('api_keys', function (Blueprint $table) { + Schema::table('egg_variables', function (Blueprint $table) { $table->text('rules')->change(); }); From 8c64a4ad55e970805e74dfce5748b5c814a10f31 Mon Sep 17 00:00:00 2001 From: notCharles Date: Sat, 7 Sep 2024 09:47:39 -0400 Subject: [PATCH 09/12] Make MySQL Happy MySQL complains when we try to change the rules column to json before we change the data... If we change the data, then change the column its happy. :) --- .../2024_07_25_072050_convert_rules_to_array.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/database/migrations/2024_07_25_072050_convert_rules_to_array.php b/database/migrations/2024_07_25_072050_convert_rules_to_array.php index 54eb7b1364..6646a66614 100644 --- a/database/migrations/2024_07_25_072050_convert_rules_to_array.php +++ b/database/migrations/2024_07_25_072050_convert_rules_to_array.php @@ -12,13 +12,13 @@ */ public function up(): void { - Schema::table('egg_variables', function (Blueprint $table) { - $table->json('rules')->change(); - }); - DB::table('egg_variables')->select(['id', 'rules'])->cursor()->each(function ($eggVariable) { DB::table('egg_variables')->where('id', $eggVariable->id)->update(['rules' => explode('|', $eggVariable->rules)]); }); + + Schema::table('egg_variables', function (Blueprint $table) { + $table->json('rules')->change(); + }); } /** From 8497e8b009df8b71d183fa57619cc599a9b2d6b9 Mon Sep 17 00:00:00 2001 From: Charles Date: Sat, 7 Sep 2024 12:45:25 -0400 Subject: [PATCH 10/12] Update egg-bungeecord.json (#571) --- database/Seeders/eggs/minecraft/egg-bungeecord.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/database/Seeders/eggs/minecraft/egg-bungeecord.json b/database/Seeders/eggs/minecraft/egg-bungeecord.json index 49bc8be50b..e71b8d1ec1 100644 --- a/database/Seeders/eggs/minecraft/egg-bungeecord.json +++ b/database/Seeders/eggs/minecraft/egg-bungeecord.json @@ -49,7 +49,7 @@ "alpha_num", "between:1,6" ], - "sort": null, + "sort": 1, "field_type": "text" }, { @@ -63,8 +63,8 @@ "required", "regex:\/^([\\w\\d._-]+)(\\.jar)$\/" ], - "sort": null, + "sort": 2, "field_type": "text" } ] -} \ No newline at end of file +} From 68a0cbbf102ea4116cf5e0b3553da42ae9afb995 Mon Sep 17 00:00:00 2001 From: ash Date: Mon, 16 Sep 2024 10:16:25 -0400 Subject: [PATCH 11/12] Update placeholders & panel error command (#576) --- .github/ISSUE_TEMPLATE/bug-report.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index fd1b1e6f77..c9cb05955b 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -33,7 +33,6 @@ body: attributes: label: Panel Version description: Version number of your Panel (latest is not a version) - placeholder: 1.4.0 validations: required: true @@ -42,7 +41,6 @@ body: attributes: label: Wings Version description: Version number of your Wings (latest is not a version) - placeholder: 1.4.2 validations: required: true @@ -68,7 +66,7 @@ body: Run the following command to collect logs on your system. Wings: `sudo wings diagnostics` - Panel: `tail -n 150 /var/www/pelican/storage/logs/laravel-$(date +%F).log | nc pelipaste.com 99` + Panel: `tail -n 150 /var/www/pelican/storage/logs/laravel-$(date +%F).log | curl -X POST -F 'c=@-' paste.pelistuff.com` placeholder: "https://pelipaste.com/a1h6z" render: bash validations: From fc643f57f9c2d862fc0d3644ca98945a1afcd599 Mon Sep 17 00:00:00 2001 From: Boy132 Date: Sat, 21 Sep 2024 12:27:41 +0200 Subject: [PATCH 12/12] Admin Roles (#502) * add spatie/permissions * add policies * add role resource * add root admin role handling * replace some "root_admin" with function * add model specific permissions * make permission selection nicer * fix user creation * fix tests * add back subuser checks in server policy * add custom model for role * assign new users to role if root_admin is set * add api for roles * fix phpstan * add permissions for settings page * remove "restore" and "forceDelete" permissions * add user count to list * prevent deletion if role has users * update user list * fix server policy * remove old `root_admin` column * small refactor * fix tests * forgot can checks here * forgot use * disable editing own roles & disable assigning root admin * don't allow to rename root admin role * remove php bombing exception handler * fix role assignment when creating a user * fix disableOptionWhen * fix missing `root_admin` attribute on react frontend * add permission check for bulk delete * rename viewAny to viewList * improve canAccessPanel check * fix admin not displaying for non-root admins * make sure non root admins can't edit root admins * fix import * fix settings page permission check * fix server permissions for non-subusers * fix settings page permission check v2 * small cleanup * cleanup config file * move consts from resouce into enum & model * Update database/migrations/2024_08_01_114538_remove_root_admin_column.php Co-authored-by: Lance Pioch * fix config * fix phpstan * fix phpstan 2.0 --------- Co-authored-by: Lance Pioch --- app/Console/Commands/User/MakeUserCommand.php | 2 +- app/Enums/RolePermissionModels.php | 16 ++ app/Enums/RolePermissionPrefixes.php | 12 ++ app/Filament/Pages/Settings.php | 14 +- .../Pages/ListDatabaseHosts.php | 3 +- .../DatabaseResource/Pages/ListDatabases.php | 5 +- .../Resources/EggResource/Pages/EditEgg.php | 17 +- .../Resources/EggResource/Pages/ListEggs.php | 11 +- .../MountResource/Pages/ListMounts.php | 3 +- .../NodeResource/Pages/ListNodes.php | 3 +- .../AllocationsRelationManager.php | 9 +- app/Filament/Resources/RoleResource.php | 146 ++++++++++++++++++ .../RoleResource/Pages/CreateRole.php | 48 ++++++ .../Resources/RoleResource/Pages/EditRole.php | 56 +++++++ .../RoleResource/Pages/ListRoles.php | 68 ++++++++ .../ServerResource/Pages/CreateServer.php | 17 +- .../ServerResource/Pages/ListServers.php | 9 +- .../Resources/UserResource/Pages/EditUser.php | 63 +++----- .../UserResource/Pages/ListUsers.php | 101 ++++++------ .../Api/Application/Roles/RoleController.php | 88 +++++++++++ .../Api/Application/Users/UserController.php | 14 ++ .../Api/Client/ClientController.php | 2 +- .../Client/Servers/ActivityLogController.php | 6 +- .../Remote/SftpAuthenticationController.php | 2 +- app/Http/Middleware/AdminAuthenticate.php | 2 +- .../AuthenticateApplicationUser.php | 2 +- .../Server/AuthenticateServerAccess.php | 4 +- .../RequireTwoFactorAuthentication.php | 2 +- app/Http/Requests/Admin/AdminFormRequest.php | 2 +- .../Requests/Admin/NewUserFormRequest.php | 1 - app/Http/Requests/Admin/UserFormRequest.php | 1 - .../Application/Roles/DeleteRoleRequest.php | 13 ++ .../Api/Application/Roles/GetRoleRequest.php | 13 ++ .../Application/Roles/StoreRoleRequest.php | 21 +++ .../Application/Roles/UpdateRoleRequest.php | 7 + .../Users/AssignUserRolesRequest.php | 17 ++ .../Application/Users/StoreUserRequest.php | 2 - .../Servers/Subusers/SubuserRequest.php | 2 +- app/Models/Role.php | 48 ++++++ app/Models/User.php | 39 +++-- app/Policies/ApiKeyPolicy.php | 10 ++ app/Policies/DatabaseHostPolicy.php | 10 ++ app/Policies/DatabasePolicy.php | 10 ++ app/Policies/DefaultPolicies.php | 49 ++++++ app/Policies/EggPolicy.php | 9 +- app/Policies/MountPolicy.php | 10 ++ app/Policies/NodePolicy.php | 10 ++ app/Policies/RolePolicy.php | 10 ++ app/Policies/ServerPolicy.php | 36 +++-- app/Policies/UserPolicy.php | 26 ++++ app/Providers/AppServiceProvider.php | 5 + app/Services/Acl/Api/AdminAcl.php | 1 + .../Servers/GetUserPermissionsService.php | 4 +- app/Services/Users/UserCreationService.php | 8 + .../Api/Application/BaseTransformer.php | 2 +- .../Application/RolePermissionTransformer.php | 23 +++ .../Api/Application/RoleTransformer.php | 47 ++++++ .../Api/Application/UserTransformer.php | 24 ++- .../Api/Client/ActivityLogTransformer.php | 2 +- .../Api/Client/UserTransformer.php | 4 +- composer.json | 1 + composer.lock | 84 +++++++++- config/permission.php | 13 ++ database/Factories/UserFactory.php | 9 -- database/Seeders/DatabaseSeeder.php | 3 + ..._07_19_130942_create_permission_tables.php | 140 +++++++++++++++++ ..._08_01_114538_remove_root_admin_column.php | 35 +++++ lang/en/admin/user.php | 1 - resources/scripts/components/App.tsx | 2 + .../scripts/components/NavigationBar.tsx | 4 +- resources/scripts/state/user.ts | 1 + routes/api-application.php | 21 +++ .../ApplicationApiIntegrationTestCase.php | 8 +- .../Users/ExternalUserControllerTest.php | 2 +- .../Application/Users/UserControllerTest.php | 4 +- .../Api/Client/ClientControllerTest.php | 7 +- .../SftpAuthenticationControllerTest.php | 5 +- tests/TestCase.php | 3 + tests/Traits/Http/RequestMockHelpers.php | 9 +- .../Http/Middleware/AdminAuthenticateTest.php | 9 +- .../Api/Application/AuthenticateUserTest.php | 4 +- 81 files changed, 1336 insertions(+), 220 deletions(-) create mode 100644 app/Enums/RolePermissionModels.php create mode 100644 app/Enums/RolePermissionPrefixes.php create mode 100644 app/Filament/Resources/RoleResource.php create mode 100644 app/Filament/Resources/RoleResource/Pages/CreateRole.php create mode 100644 app/Filament/Resources/RoleResource/Pages/EditRole.php create mode 100644 app/Filament/Resources/RoleResource/Pages/ListRoles.php create mode 100644 app/Http/Controllers/Api/Application/Roles/RoleController.php create mode 100644 app/Http/Requests/Api/Application/Roles/DeleteRoleRequest.php create mode 100644 app/Http/Requests/Api/Application/Roles/GetRoleRequest.php create mode 100644 app/Http/Requests/Api/Application/Roles/StoreRoleRequest.php create mode 100644 app/Http/Requests/Api/Application/Roles/UpdateRoleRequest.php create mode 100644 app/Http/Requests/Api/Application/Users/AssignUserRolesRequest.php create mode 100644 app/Models/Role.php create mode 100644 app/Policies/ApiKeyPolicy.php create mode 100644 app/Policies/DatabaseHostPolicy.php create mode 100644 app/Policies/DatabasePolicy.php create mode 100644 app/Policies/DefaultPolicies.php create mode 100644 app/Policies/MountPolicy.php create mode 100644 app/Policies/NodePolicy.php create mode 100644 app/Policies/RolePolicy.php create mode 100644 app/Policies/UserPolicy.php create mode 100644 app/Transformers/Api/Application/RolePermissionTransformer.php create mode 100644 app/Transformers/Api/Application/RoleTransformer.php create mode 100644 config/permission.php create mode 100644 database/migrations/2024_07_19_130942_create_permission_tables.php create mode 100644 database/migrations/2024_08_01_114538_remove_root_admin_column.php diff --git a/app/Console/Commands/User/MakeUserCommand.php b/app/Console/Commands/User/MakeUserCommand.php index 20de2ec206..320f07f534 100644 --- a/app/Console/Commands/User/MakeUserCommand.php +++ b/app/Console/Commands/User/MakeUserCommand.php @@ -52,7 +52,7 @@ public function handle(): int ['UUID', $user->uuid], ['Email', $user->email], ['Username', $user->username], - ['Admin', $user->root_admin ? 'Yes' : 'No'], + ['Admin', $user->isRootAdmin() ? 'Yes' : 'No'], ]); return 0; diff --git a/app/Enums/RolePermissionModels.php b/app/Enums/RolePermissionModels.php new file mode 100644 index 0000000000..0a80d5a56c --- /dev/null +++ b/app/Enums/RolePermissionModels.php @@ -0,0 +1,16 @@ +form->fill(); } + public static function canAccess(): bool + { + return auth()->user()->can('view settings'); + } + protected function getFormSchema(): array { return [ Tabs::make('Tabs') ->columns() ->persistTabInQueryString() + ->disabled(fn () => !auth()->user()->can('update settings')) ->tabs([ Tab::make('general') ->label('General') @@ -147,10 +153,12 @@ private function generalSettings(): array ->color('danger') ->icon('tabler-trash') ->requiresConfirmation() + ->authorize(fn () => auth()->user()->can('update settings')) ->action(fn (Set $set) => $set('TRUSTED_PROXIES', [])), FormAction::make('cloudflare') ->label('Set to Cloudflare IPs') ->icon('tabler-brand-cloudflare') + ->authorize(fn () => auth()->user()->can('update settings')) ->action(fn (Set $set) => $set('TRUSTED_PROXIES', [ '173.245.48.0/20', '103.21.244.0/22', @@ -226,6 +234,7 @@ private function mailSettings(): array ->label('Send Test Mail') ->icon('tabler-send') ->hidden(fn (Get $get) => $get('MAIL_MAILER') === 'log') + ->authorize(fn () => auth()->user()->can('update settings')) ->action(function () { try { MailNotification::route('mail', auth()->user()->email) @@ -561,12 +570,9 @@ protected function getHeaderActions(): array return [ Action::make('save') ->action('save') + ->authorize(fn () => auth()->user()->can('update settings')) ->keyBindings(['mod+s']), ]; } - protected function getFormActions(): array - { - return []; - } } diff --git a/app/Filament/Resources/DatabaseHostResource/Pages/ListDatabaseHosts.php b/app/Filament/Resources/DatabaseHostResource/Pages/ListDatabaseHosts.php index 9307488f88..18c4b2fd71 100644 --- a/app/Filament/Resources/DatabaseHostResource/Pages/ListDatabaseHosts.php +++ b/app/Filament/Resources/DatabaseHostResource/Pages/ListDatabaseHosts.php @@ -42,7 +42,8 @@ public function table(Table $table): Table ]) ->bulkActions([ BulkActionGroup::make([ - DeleteBulkAction::make(), + DeleteBulkAction::make() + ->authorize(fn () => auth()->user()->can('delete databasehost')), ]), ]); } diff --git a/app/Filament/Resources/DatabaseResource/Pages/ListDatabases.php b/app/Filament/Resources/DatabaseResource/Pages/ListDatabases.php index 45df4a5970..6dfe7762a1 100644 --- a/app/Filament/Resources/DatabaseResource/Pages/ListDatabases.php +++ b/app/Filament/Resources/DatabaseResource/Pages/ListDatabases.php @@ -4,10 +4,10 @@ use App\Filament\Resources\DatabaseResource; use Filament\Actions; -use Filament\Tables\Actions\EditAction; use Filament\Resources\Pages\ListRecords; use Filament\Tables\Actions\BulkActionGroup; use Filament\Tables\Actions\DeleteBulkAction; +use Filament\Tables\Actions\EditAction; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; @@ -48,7 +48,8 @@ public function table(Table $table): Table ]) ->bulkActions([ BulkActionGroup::make([ - DeleteBulkAction::make(), + DeleteBulkAction::make() + ->authorize(fn () => auth()->user()->can('delete database')), ]), ]); } diff --git a/app/Filament/Resources/EggResource/Pages/EditEgg.php b/app/Filament/Resources/EggResource/Pages/EditEgg.php index ee7bf5bd62..212aec8f2b 100644 --- a/app/Filament/Resources/EggResource/Pages/EditEgg.php +++ b/app/Filament/Resources/EggResource/Pages/EditEgg.php @@ -2,12 +2,15 @@ namespace App\Filament\Resources\EggResource\Pages; +use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor; use App\Filament\Resources\EggResource; use App\Filament\Resources\EggResource\RelationManagers\ServersRelationManager; use App\Models\Egg; +use App\Services\Eggs\Sharing\EggExporterService; use App\Services\Eggs\Sharing\EggImporterService; use Exception; use Filament\Actions; +use Filament\Forms; use Filament\Forms\Components\Checkbox; use Filament\Forms\Components\Fieldset; use Filament\Forms\Components\FileUpload; @@ -22,12 +25,9 @@ use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; +use Filament\Forms\Form; use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; -use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor; -use App\Services\Eggs\Sharing\EggExporterService; -use Filament\Forms; -use Filament\Forms\Form; class EditEgg extends EditRecord { @@ -245,14 +245,13 @@ protected function getHeaderActions(): array Actions\DeleteAction::make('deleteEgg') ->disabled(fn (Egg $egg): bool => $egg->servers()->count() > 0) ->label(fn (Egg $egg): string => $egg->servers()->count() <= 0 ? 'Delete' : 'In Use'), - Actions\Action::make('exportEgg') ->label('Export') ->color('primary') ->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) { echo $service->handle($egg->id); - }, 'egg-' . $egg->getKebabName() . '.json')), - + }, 'egg-' . $egg->getKebabName() . '.json')) + ->authorize(fn () => auth()->user()->can('export egg')), Actions\Action::make('importEgg') ->label('Import') ->form([ @@ -321,8 +320,8 @@ protected function getHeaderActions(): array ->title('Import Success') ->success() ->send(); - }), - + }) + ->authorize(fn () => auth()->user()->can('import egg')), $this->getSaveFormAction()->formId('form'), ]; } diff --git a/app/Filament/Resources/EggResource/Pages/ListEggs.php b/app/Filament/Resources/EggResource/Pages/ListEggs.php index 947d2231a7..1a6a3b6509 100644 --- a/app/Filament/Resources/EggResource/Pages/ListEggs.php +++ b/app/Filament/Resources/EggResource/Pages/ListEggs.php @@ -14,13 +14,13 @@ use Filament\Forms\Components\TextInput; use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; +use Filament\Tables; use Filament\Tables\Actions\BulkActionGroup; use Filament\Tables\Actions\DeleteBulkAction; use Filament\Tables\Actions\EditAction; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; -use Filament\Tables; class ListEggs extends ListRecords { @@ -55,11 +55,13 @@ public function table(Table $table): Table ->color('primary') ->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) { echo $service->handle($egg->id); - }, 'egg-' . $egg->getKebabName() . '.json')), + }, 'egg-' . $egg->getKebabName() . '.json')) + ->authorize(fn () => auth()->user()->can('export egg')), ]) ->bulkActions([ BulkActionGroup::make([ - DeleteBulkAction::make(), + DeleteBulkAction::make() + ->authorize(fn () => auth()->user()->can('delete egg')), ]), ]); } @@ -138,7 +140,8 @@ protected function getHeaderActions(): array ->title('Import Success') ->success() ->send(); - }), + }) + ->authorize(fn () => auth()->user()->can('import egg')), ]; } } diff --git a/app/Filament/Resources/MountResource/Pages/ListMounts.php b/app/Filament/Resources/MountResource/Pages/ListMounts.php index ea3970df5c..d39c5d972d 100644 --- a/app/Filament/Resources/MountResource/Pages/ListMounts.php +++ b/app/Filament/Resources/MountResource/Pages/ListMounts.php @@ -43,7 +43,8 @@ public function table(Table $table): Table ]) ->bulkActions([ BulkActionGroup::make([ - DeleteBulkAction::make(), + DeleteBulkAction::make() + ->authorize(fn () => auth()->user()->can('delete mount')), ]), ]) ->emptyStateIcon('tabler-layers-linked') diff --git a/app/Filament/Resources/NodeResource/Pages/ListNodes.php b/app/Filament/Resources/NodeResource/Pages/ListNodes.php index 6ecb6b690a..62f0254b89 100644 --- a/app/Filament/Resources/NodeResource/Pages/ListNodes.php +++ b/app/Filament/Resources/NodeResource/Pages/ListNodes.php @@ -84,7 +84,8 @@ public function table(Table $table): Table ]) ->bulkActions([ BulkActionGroup::make([ - DeleteBulkAction::make(), + DeleteBulkAction::make() + ->authorize(fn () => auth()->user()->can('delete node')), ]), ]) ->emptyStateIcon('tabler-server-2') diff --git a/app/Filament/Resources/NodeResource/RelationManagers/AllocationsRelationManager.php b/app/Filament/Resources/NodeResource/RelationManagers/AllocationsRelationManager.php index d30df84450..01cc5e50e5 100644 --- a/app/Filament/Resources/NodeResource/RelationManagers/AllocationsRelationManager.php +++ b/app/Filament/Resources/NodeResource/RelationManagers/AllocationsRelationManager.php @@ -7,12 +7,12 @@ use App\Services\Allocations\AssignmentService; use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\TextInput; -use Filament\Forms\Set; -use Filament\Tables\Actions\BulkActionGroup; -use Filament\Tables\Actions\DeleteBulkAction; use Filament\Forms\Form; +use Filament\Forms\Set; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; +use Filament\Tables\Actions\BulkActionGroup; +use Filament\Tables\Actions\DeleteBulkAction; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextInputColumn; use Filament\Tables\Table; @@ -152,7 +152,8 @@ public function table(Table $table): Table ]) ->bulkActions([ BulkActionGroup::make([ - DeleteBulkAction::make(), + DeleteBulkAction::make() + ->authorize(fn () => auth()->user()->can('delete allocation')), ]), ]); } diff --git a/app/Filament/Resources/RoleResource.php b/app/Filament/Resources/RoleResource.php new file mode 100644 index 0000000000..306c9676da --- /dev/null +++ b/app/Filament/Resources/RoleResource.php @@ -0,0 +1,146 @@ +value . ' ' . strtolower($model->value)] = Str::headline($prefix->value); + } + + if (array_key_exists($model->value, Role::MODEL_SPECIFIC_PERMISSIONS)) { + foreach (Role::MODEL_SPECIFIC_PERMISSIONS[$model->value] as $permission) { + $options[$permission . ' ' . strtolower($model->value)] = Str::headline($permission); + } + } + + $permissions[] = self::makeSection($model->value, $options); + } + + foreach (Role::SPECIAL_PERMISSIONS as $model => $prefixes) { + $options = []; + + foreach ($prefixes as $prefix) { + $options[$prefix . ' ' . strtolower($model)] = Str::headline($prefix); + } + + $permissions[] = self::makeSection($model, $options); + } + + return $form + ->columns(1) + ->schema([ + TextInput::make('name') + ->label('Role Name') + ->required() + ->disabled(fn (Get $get) => $get('name') === Role::ROOT_ADMIN), + TextInput::make('guard_name') + ->label('Guard Name') + ->default(Filament::getCurrentPanel()?->getAuthGuard() ?? '') + ->nullable() + ->hidden(), + Fieldset::make('Permissions') + ->columns(3) + ->schema($permissions) + ->hidden(fn (Get $get) => $get('name') === Role::ROOT_ADMIN), + Placeholder::make('permissions') + ->content('The Root Admin has all permissions.') + ->visible(fn (Get $get) => $get('name') === Role::ROOT_ADMIN), + ]); + } + + private static function makeSection(string $model, array $options): Section + { + $icon = null; + + if (class_exists('\App\Filament\Resources\\' . $model . 'Resource')) { + $icon = ('\App\Filament\Resources\\' . $model . 'Resource')::getNavigationIcon(); + } elseif (class_exists('\App\Filament\Pages\\' . $model)) { + $icon = ('\App\Filament\Pages\\' . $model)::getNavigationIcon(); + } + + return Section::make(Str::headline(Str::plural($model))) + ->columnSpan(1) + ->collapsible() + ->collapsed() + ->icon($icon) + ->headerActions([ + Action::make('count') + ->label(fn (Get $get) => count($get(strtolower($model) . '_list'))) + ->badge(), + ]) + ->schema([ + CheckboxList::make(strtolower($model) . '_list') + ->label('') + ->options($options) + ->columns() + ->gridDirection('row') + ->bulkToggleable() + ->live() + ->afterStateHydrated( + function (Component $component, string $operation, ?Role $record) use ($options) { + if (in_array($operation, ['edit', 'view'])) { + + if (blank($record)) { + return; + } + + if ($component->isVisible()) { + $component->state( + collect($options) + ->filter(fn ($value, $key) => $record->checkPermissionTo($key)) + ->keys() + ->toArray() + ); + } + } + } + ) + ->dehydrated(fn ($state) => !blank($state)), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListRoles::route('/'), + 'create' => Pages\CreateRole::route('/create'), + 'edit' => Pages\EditRole::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/RoleResource/Pages/CreateRole.php b/app/Filament/Resources/RoleResource/Pages/CreateRole.php new file mode 100644 index 0000000000..2f69bba011 --- /dev/null +++ b/app/Filament/Resources/RoleResource/Pages/CreateRole.php @@ -0,0 +1,48 @@ +permissions = collect($data) + ->filter(function ($permission, $key) { + return !in_array($key, ['name', 'guard_name']); + }) + ->values() + ->flatten() + ->unique(); + + return Arr::only($data, ['name', 'guard_name']); + } + + protected function afterCreate(): void + { + $permissionModels = collect(); + $this->permissions->each(function ($permission) use ($permissionModels) { + $permissionModels->push(Permission::firstOrCreate([ + 'name' => $permission, + 'guard_name' => $this->data['guard_name'], + ])); + }); + + $this->record->syncPermissions($permissionModels); + } +} diff --git a/app/Filament/Resources/RoleResource/Pages/EditRole.php b/app/Filament/Resources/RoleResource/Pages/EditRole.php new file mode 100644 index 0000000000..c62e126913 --- /dev/null +++ b/app/Filament/Resources/RoleResource/Pages/EditRole.php @@ -0,0 +1,56 @@ +permissions = collect($data) + ->filter(function ($permission, $key) { + return !in_array($key, ['name', 'guard_name']); + }) + ->values() + ->flatten() + ->unique(); + + return Arr::only($data, ['name', 'guard_name']); + } + + protected function afterSave(): void + { + $permissionModels = collect(); + $this->permissions->each(function ($permission) use ($permissionModels) { + $permissionModels->push(Permission::firstOrCreate([ + 'name' => $permission, + 'guard_name' => $this->data['guard_name'], + ])); + }); + + $this->record->syncPermissions($permissionModels); + } + + protected function getHeaderActions(): array + { + return [ + DeleteAction::make() + ->disabled(fn (Role $role) => $role->isRootAdmin() || $role->users_count >= 1) + ->label(fn (Role $role) => $role->isRootAdmin() ? 'Can\'t delete Root Admin' : ($role->users_count >= 1 ? 'In Use' : 'Delete')), + ]; + } +} diff --git a/app/Filament/Resources/RoleResource/Pages/ListRoles.php b/app/Filament/Resources/RoleResource/Pages/ListRoles.php new file mode 100644 index 0000000000..ac83be15db --- /dev/null +++ b/app/Filament/Resources/RoleResource/Pages/ListRoles.php @@ -0,0 +1,68 @@ +columns([ + TextColumn::make('name') + ->sortable() + ->searchable(), + TextColumn::make('guard_name') + ->hidden() + ->sortable() + ->searchable(), + TextColumn::make('permissions_count') + ->label('Permissions') + ->badge() + ->counts('permissions') + ->formatStateUsing(fn (Role $role, $state) => $role->isRootAdmin() ? 'All' : $state), + TextColumn::make('users_count') + ->label('Users') + ->counts('users') + ->icon('tabler-users'), + ]) + ->actions([ + EditAction::make(), + ]) + ->checkIfRecordIsSelectableUsing(fn (Role $role) => !$role->isRootAdmin() && $role->users_count <= 0) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make() + ->authorize(fn () => auth()->user()->can('delete role')), + ]), + ]) + ->emptyStateIcon('tabler-users-group') + ->emptyStateDescription('') + ->emptyStateHeading('No Roles') + ->emptyStateActions([ + CreateActionTable::make('create') + ->label('Create Role') + ->button(), + ]); + } + + protected function getHeaderActions(): array + { + return [ + CreateAction::make() + ->label('Create Role'), + ]; + } +} diff --git a/app/Filament/Resources/ServerResource/Pages/CreateServer.php b/app/Filament/Resources/ServerResource/Pages/CreateServer.php index 6e47053453..8264eb9f55 100644 --- a/app/Filament/Resources/ServerResource/Pages/CreateServer.php +++ b/app/Filament/Resources/ServerResource/Pages/CreateServer.php @@ -81,7 +81,7 @@ public function form(Form $form): Form ]) ->relationship('user', 'username') ->searchable(['username', 'email']) - ->getOptionLabelFromRecordUsing(fn (User $user) => "$user->email | $user->username " . ($user->root_admin ? '(admin)' : '')) + ->getOptionLabelFromRecordUsing(fn (User $user) => "$user->email | $user->username " . ($user->isRootAdmin() ? '(admin)' : '')) ->createOptionForm([ Forms\Components\TextInput::make('username') ->alphaNum() @@ -98,21 +98,6 @@ public function form(Form $form): Form ->hintIcon('tabler-question-mark') ->hintIconTooltip('Providing a user password is optional. New user email will prompt users to create a password the first time they login.') ->password(), - - Forms\Components\ToggleButtons::make('root_admin') - ->label('Administrator (Root)') - ->options([ - false => 'No', - true => 'Admin', - ]) - ->colors([ - false => 'primary', - true => 'danger', - ]) - ->inline() - ->required() - ->default(false) - ->hidden(), ]) ->createOptionUsing(function ($data) { resolve(UserCreationService::class)->handle($data); diff --git a/app/Filament/Resources/ServerResource/Pages/ListServers.php b/app/Filament/Resources/ServerResource/Pages/ListServers.php index bd0f1fa21d..b0bc6ef9bc 100644 --- a/app/Filament/Resources/ServerResource/Pages/ListServers.php +++ b/app/Filament/Resources/ServerResource/Pages/ListServers.php @@ -4,6 +4,7 @@ use App\Filament\Resources\ServerResource; use App\Models\Server; +use App\Models\User; use Filament\Actions; use Filament\Resources\Pages\ListRecords; use Filament\Tables\Actions\CreateAction; @@ -76,7 +77,13 @@ public function table(Table $table): Table ->actions([ Tables\Actions\Action::make('View') ->icon('tabler-terminal') - ->url(fn (Server $server) => "/server/$server->uuid_short"), + ->url(fn (Server $server) => "/server/$server->uuid_short") + ->visible(function (Server $server) { + /** @var User $user */ + $user = auth()->user(); + + return $user->isRootAdmin() || $user->id === $server->owner_id; + }), Tables\Actions\EditAction::make(), ]) ->emptyStateIcon('tabler-brand-docker') diff --git a/app/Filament/Resources/UserResource/Pages/EditUser.php b/app/Filament/Resources/UserResource/Pages/EditUser.php index 6e63864053..777f20d730 100644 --- a/app/Filament/Resources/UserResource/Pages/EditUser.php +++ b/app/Filament/Resources/UserResource/Pages/EditUser.php @@ -3,13 +3,16 @@ namespace App\Filament\Resources\UserResource\Pages; use App\Filament\Resources\UserResource; -use App\Services\Exceptions\FilamentExceptionHandler; -use Filament\Actions; -use Filament\Resources\Pages\EditRecord; +use App\Models\Role; use App\Models\User; -use Filament\Forms; +use Filament\Actions\DeleteAction; +use Filament\Forms\Components\CheckboxList; +use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Section; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; use Filament\Forms\Form; +use Filament\Resources\Pages\EditRecord; use Illuminate\Support\Facades\Hash; class EditUser extends EditRecord @@ -20,54 +23,33 @@ public function form(Form $form): Form return $form ->schema([ Section::make()->schema([ - Forms\Components\TextInput::make('username')->required()->maxLength(255), - Forms\Components\TextInput::make('email')->email()->required()->maxLength(255), - - Forms\Components\TextInput::make('password') + TextInput::make('username')->required()->maxLength(255), + TextInput::make('email')->email()->required()->maxLength(255), + TextInput::make('password') ->dehydrateStateUsing(fn (string $state): string => Hash::make($state)) ->dehydrated(fn (?string $state): bool => filled($state)) ->required(fn (string $operation): bool => $operation === 'create') ->password(), - - Forms\Components\ToggleButtons::make('root_admin') - ->label('Administrator (Root)') - ->options([ - false => 'No', - true => 'Admin', - ]) - ->colors([ - false => 'primary', - true => 'danger', - ]) - ->disableOptionWhen(function (string $operation, $value, User $user) { - if ($operation !== 'edit' || $value) { - return false; - } - - return $user->isLastRootAdmin(); - }) - ->hint(fn (User $user) => $user->isLastRootAdmin() ? 'This is the last root administrator!' : '') - ->helperText(fn (User $user) => $user->isLastRootAdmin() ? 'You must have at least one root administrator in your system.' : '') - ->hintColor('warning') - ->inline() - ->required() - ->default(false), - - Forms\Components\Hidden::make('skipValidation')->default(true), - - Forms\Components\Select::make('language') + Select::make('language') ->required() ->hidden() ->default('en') ->options(fn (User $user) => $user->getAvailableLanguages()), - + Hidden::make('skipValidation')->default(true), + CheckboxList::make('roles') + ->disabled(fn (User $user) => $user->id === auth()->user()->id) + ->disableOptionWhen(fn (string $value): bool => $value == Role::getRootAdmin()->id) + ->relationship('roles', 'name') + ->label('Admin Roles') + ->columnSpanFull() + ->bulkToggleable(false), ])->columns(), ]); } protected function getHeaderActions(): array { return [ - Actions\DeleteAction::make() + DeleteAction::make() ->label(fn (User $user) => auth()->user()->id === $user->id ? 'Can\'t Delete Yourself' : ($user->servers()->count() > 0 ? 'User Has Servers' : 'Delete')) ->disabled(fn (User $user) => auth()->user()->id === $user->id || $user->servers()->count() > 0), $this->getSaveFormAction()->formId('form'), @@ -78,9 +60,4 @@ protected function getFormActions(): array { return []; } - - public function exception($exception, $stopPropagation): void - { - (new FilamentExceptionHandler())->handle($exception, $stopPropagation); - } } diff --git a/app/Filament/Resources/UserResource/Pages/ListUsers.php b/app/Filament/Resources/UserResource/Pages/ListUsers.php index 42dc17fab2..3d9a1af185 100644 --- a/app/Filament/Resources/UserResource/Pages/ListUsers.php +++ b/app/Filament/Resources/UserResource/Pages/ListUsers.php @@ -3,14 +3,22 @@ namespace App\Filament\Resources\UserResource\Pages; use App\Filament\Resources\UserResource; +use App\Models\Role; use App\Models\User; use App\Services\Users\UserCreationService; -use Filament\Actions; +use Filament\Actions\CreateAction; +use Filament\Forms\Components\CheckboxList; +use Filament\Forms\Components\Grid; +use Filament\Forms\Components\TextInput; use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; +use Filament\Tables\Actions\BulkActionGroup; +use Filament\Tables\Actions\DeleteBulkAction; +use Filament\Tables\Actions\EditAction; +use Filament\Tables\Columns\IconColumn; +use Filament\Tables\Columns\ImageColumn; +use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; -use Filament\Tables; -use Filament\Forms; class ListUsers extends ListRecords { @@ -21,101 +29,102 @@ public function table(Table $table): Table return $table ->searchable(false) ->columns([ - Tables\Columns\ImageColumn::make('picture') + ImageColumn::make('picture') ->visibleFrom('lg') ->label('') ->extraImgAttributes(['class' => 'rounded-full']) ->defaultImageUrl(fn (User $user) => 'https://gravatar.com/avatar/' . md5(strtolower($user->email))), - Tables\Columns\TextColumn::make('external_id') + TextColumn::make('external_id') ->searchable() ->hidden(), - Tables\Columns\TextColumn::make('uuid') + TextColumn::make('uuid') ->label('UUID') ->hidden() ->searchable(), - Tables\Columns\TextColumn::make('username') + TextColumn::make('username') ->searchable(), - Tables\Columns\TextColumn::make('email') + TextColumn::make('email') ->searchable() ->icon('tabler-mail'), - Tables\Columns\IconColumn::make('root_admin') - ->visibleFrom('md') - ->label('Admin') - ->boolean() - ->trueIcon('tabler-star-filled') - ->falseIcon('tabler-star-off') - ->sortable(), - Tables\Columns\IconColumn::make('use_totp')->label('2FA') + IconColumn::make('use_totp') + ->label('2FA') ->visibleFrom('lg') ->icon(fn (User $user) => $user->use_totp ? 'tabler-lock' : 'tabler-lock-open-off') ->boolean()->sortable(), - Tables\Columns\TextColumn::make('servers_count') + TextColumn::make('roles_count') + ->counts('roles') + ->icon('tabler-users-group') + ->label('Roles') + ->formatStateUsing(fn (User $user, $state) => $state . ($user->isRootAdmin() ? ' (Root Admin)' : '')), + TextColumn::make('servers_count') ->counts('servers') ->icon('tabler-server') ->label('Servers'), - Tables\Columns\TextColumn::make('subusers_count') + TextColumn::make('subusers_count') ->visibleFrom('sm') ->label('Subusers') ->counts('subusers') ->icon('tabler-users'), // ->formatStateUsing(fn (string $state, $record): string => (string) ($record->servers_count + $record->subusers_count)) ]) - ->filters([ - // - ]) ->actions([ - Tables\Actions\EditAction::make(), + EditAction::make(), ]) ->checkIfRecordIsSelectableUsing(fn (User $user) => auth()->user()->id !== $user->id && !$user->servers_count) ->bulkActions([ - Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), + BulkActionGroup::make([ + DeleteBulkAction::make() + ->authorize(fn () => auth()->user()->can('delete user')), ]), ]); } protected function getHeaderActions(): array { return [ - Actions\CreateAction::make('create') + CreateAction::make('create') ->label('Create User') ->createAnother(false) ->form([ - Forms\Components\Grid::make() + Grid::make() ->schema([ - Forms\Components\TextInput::make('username') + TextInput::make('username') ->alphaNum() ->required() ->maxLength(255), - Forms\Components\TextInput::make('email') + TextInput::make('email') ->email() ->required() ->unique() ->maxLength(255), - - Forms\Components\TextInput::make('password') + TextInput::make('password') ->hintIcon('tabler-question-mark') ->hintIconTooltip('Providing a user password is optional. New user email will prompt users to create a password the first time they login.') ->password(), - - Forms\Components\ToggleButtons::make('root_admin') - ->label('Administrator (Root)') - ->options([ - false => 'No', - true => 'Admin', - ]) - ->colors([ - false => 'primary', - true => 'danger', - ]) - ->inline() - ->required() - ->default(false), + CheckboxList::make('roles') + ->disableOptionWhen(fn (string $value): bool => $value == Role::getRootAdmin()->id) + ->relationship('roles', 'name') + ->dehydrated() + ->label('Admin Roles') + ->columnSpanFull() + ->bulkToggleable(false), ]), ]) ->successRedirectUrl(route('filament.admin.resources.users.index')) ->action(function (array $data) { - resolve(UserCreationService::class)->handle($data); - Notification::make()->title('User Created!')->success()->send(); + $roles = $data['roles']; + $roles = collect($roles)->map(fn ($role) => Role::findById($role)); + unset($data['roles']); + + /** @var UserCreationService $creationService */ + $creationService = resolve(UserCreationService::class); + $user = $creationService->handle($data); + + $user->syncRoles($roles); + + Notification::make() + ->title('User Created!') + ->success() + ->send(); return redirect()->route('filament.admin.resources.users.index'); }), diff --git a/app/Http/Controllers/Api/Application/Roles/RoleController.php b/app/Http/Controllers/Api/Application/Roles/RoleController.php new file mode 100644 index 0000000000..9a776c442e --- /dev/null +++ b/app/Http/Controllers/Api/Application/Roles/RoleController.php @@ -0,0 +1,88 @@ +allowedFilters(['name']) + ->allowedSorts(['name']) + ->paginate($request->query('per_page') ?? 10); + + return $this->fractal->collection($roles) + ->transformWith($this->getTransformer(RoleTransformer::class)) + ->toArray(); + } + + /** + * Return a single role. + */ + public function view(GetRoleRequest $request, Role $role): array + { + return $this->fractal->item($role) + ->transformWith($this->getTransformer(RoleTransformer::class)) + ->toArray(); + } + + /** + * Store a new role on the Panel and return an HTTP/201 response code with the + * new role attached. + * + * @throws \Throwable + */ + public function store(StoreRoleRequest $request): JsonResponse + { + $role = Role::create($request->validated()); + + return $this->fractal->item($role) + ->transformWith($this->getTransformer(RoleTransformer::class)) + ->addMeta([ + 'resource' => route('api.application.roles.view', [ + 'role' => $role->id, + ]), + ]) + ->respond(201); + } + + /** + * Update a role on the Panel and return the updated record to the user. + * + * @throws \Throwable + */ + public function update(UpdateRoleRequest $request, Role $role): array + { + $role->update($request->validated()); + + return $this->fractal->item($role) + ->transformWith($this->getTransformer(RoleTransformer::class)) + ->toArray(); + } + + /** + * Delete a role from the Panel. + * + * @throws \Exception + */ + public function delete(DeleteRoleRequest $request, Role $role): Response + { + $role->delete(); + + return $this->returnNoContent(); + } +} diff --git a/app/Http/Controllers/Api/Application/Users/UserController.php b/app/Http/Controllers/Api/Application/Users/UserController.php index a561da508d..f7ed424967 100644 --- a/app/Http/Controllers/Api/Application/Users/UserController.php +++ b/app/Http/Controllers/Api/Application/Users/UserController.php @@ -13,6 +13,7 @@ use App\Http\Requests\Api\Application\Users\DeleteUserRequest; use App\Http\Requests\Api\Application\Users\UpdateUserRequest; use App\Http\Controllers\Api\Application\ApplicationApiController; +use App\Http\Requests\Api\Application\Users\AssignUserRolesRequest; class UserController extends ApplicationApiController { @@ -75,6 +76,19 @@ public function update(UpdateUserRequest $request, User $user): array return $response->toArray(); } + /** + * Assign roles to a user. + */ + public function roles(AssignUserRolesRequest $request, User $user): array + { + $user->syncRoles($request->input('roles')); + + $response = $this->fractal->item($user) + ->transformWith($this->getTransformer(UserTransformer::class)); + + return $response->toArray(); + } + /** * Store a new user on the system. Returns the created user and an HTTP/201 * header on successful creation. diff --git a/app/Http/Controllers/Api/Client/ClientController.php b/app/Http/Controllers/Api/Client/ClientController.php index 319d599193..7ac7562f17 100644 --- a/app/Http/Controllers/Api/Client/ClientController.php +++ b/app/Http/Controllers/Api/Client/ClientController.php @@ -48,7 +48,7 @@ public function index(GetServersRequest $request): array if (in_array($type, ['admin', 'admin-all'])) { // If they aren't an admin but want all the admin servers don't fail the request, just // make it a query that will never return any results back. - if (!$user->root_admin) { + if (!$user->isRootAdmin()) { $builder->whereRaw('1 = 2'); } else { $builder = $type === 'admin-all' diff --git a/app/Http/Controllers/Api/Client/Servers/ActivityLogController.php b/app/Http/Controllers/Api/Client/Servers/ActivityLogController.php index efef026813..53272eb70d 100644 --- a/app/Http/Controllers/Api/Client/Servers/ActivityLogController.php +++ b/app/Http/Controllers/Api/Client/Servers/ActivityLogController.php @@ -13,6 +13,7 @@ use App\Http\Requests\Api\Client\ClientApiRequest; use App\Transformers\Api\Client\ActivityLogTransformer; use App\Http\Controllers\Api\Client\ClientApiController; +use App\Models\Role; class ActivityLogController extends ClientApiController { @@ -32,15 +33,16 @@ public function __invoke(ClientApiRequest $request, Server $server): array // We could do this with a query and a lot of joins, but that gets pretty // painful so for now we'll execute a simpler query. $subusers = $server->subusers()->pluck('user_id')->merge([$server->owner_id]); + $rootAdmins = Role::getRootAdmin()->users()->pluck('id'); $builder->select('activity_logs.*') ->leftJoin('users', function (JoinClause $join) { $join->on('users.id', 'activity_logs.actor_id') ->where('activity_logs.actor_type', (new User())->getMorphClass()); }) - ->where(function (Builder $builder) use ($subusers) { + ->where(function (Builder $builder) use ($subusers, $rootAdmins) { $builder->whereNull('users.id') - ->orWhere('users.root_admin', 0) + ->orWhereNotIn('users.id', $rootAdmins) ->orWhereIn('users.id', $subusers); }); }) diff --git a/app/Http/Controllers/Api/Remote/SftpAuthenticationController.php b/app/Http/Controllers/Api/Remote/SftpAuthenticationController.php index af9cf97b16..9b59704f15 100644 --- a/app/Http/Controllers/Api/Remote/SftpAuthenticationController.php +++ b/app/Http/Controllers/Api/Remote/SftpAuthenticationController.php @@ -140,7 +140,7 @@ protected function reject(Request $request, bool $increment = true): void */ protected function validateSftpAccess(User $user, Server $server): void { - if (!$user->root_admin && $server->owner_id !== $user->id) { + if (!$user->isRootAdmin() && $server->owner_id !== $user->id) { $permissions = $this->permissions->handle($server, $user); if (!in_array(Permission::ACTION_FILE_SFTP, $permissions)) { diff --git a/app/Http/Middleware/AdminAuthenticate.php b/app/Http/Middleware/AdminAuthenticate.php index dc3296b06c..6b86a86ee0 100644 --- a/app/Http/Middleware/AdminAuthenticate.php +++ b/app/Http/Middleware/AdminAuthenticate.php @@ -14,7 +14,7 @@ class AdminAuthenticate */ public function handle(Request $request, \Closure $next): mixed { - if (!$request->user() || !$request->user()->root_admin) { + if (!$request->user() || !$request->user()->isRootAdmin()) { throw new AccessDeniedHttpException(); } diff --git a/app/Http/Middleware/Api/Application/AuthenticateApplicationUser.php b/app/Http/Middleware/Api/Application/AuthenticateApplicationUser.php index a18c58baff..054739d27a 100644 --- a/app/Http/Middleware/Api/Application/AuthenticateApplicationUser.php +++ b/app/Http/Middleware/Api/Application/AuthenticateApplicationUser.php @@ -15,7 +15,7 @@ public function handle(Request $request, \Closure $next): mixed { /** @var \App\Models\User|null $user */ $user = $request->user(); - if (!$user || !$user->root_admin) { + if (!$user || !$user->isRootAdmin()) { throw new AccessDeniedHttpException('This account does not have permission to access the API.'); } diff --git a/app/Http/Middleware/Api/Client/Server/AuthenticateServerAccess.php b/app/Http/Middleware/Api/Client/Server/AuthenticateServerAccess.php index a8218b4106..a8ef5d0f38 100644 --- a/app/Http/Middleware/Api/Client/Server/AuthenticateServerAccess.php +++ b/app/Http/Middleware/Api/Client/Server/AuthenticateServerAccess.php @@ -39,7 +39,7 @@ public function handle(Request $request, \Closure $next): mixed // At the very least, ensure that the user trying to make this request is the // server owner, a subuser, or a root admin. We'll leave it up to the controllers // to authenticate more detailed permissions if needed. - if ($user->id !== $server->owner_id && !$user->root_admin) { + if ($user->id !== $server->owner_id && !$user->isRootAdmin()) { // Check for subuser status. if (!$server->subusers->contains('user_id', $user->id)) { throw new NotFoundHttpException(trans('exceptions.api.resource_not_found')); @@ -55,7 +55,7 @@ public function handle(Request $request, \Closure $next): mixed if (($server->isSuspended() || $server->node->isUnderMaintenance()) && !$request->routeIs('api:client:server.resources')) { throw $exception; } - if (!$user->root_admin || !$request->routeIs($this->except)) { + if (!$user->isRootAdmin() || !$request->routeIs($this->except)) { throw $exception; } } diff --git a/app/Http/Middleware/RequireTwoFactorAuthentication.php b/app/Http/Middleware/RequireTwoFactorAuthentication.php index ac1f4a1e27..470cc73b30 100644 --- a/app/Http/Middleware/RequireTwoFactorAuthentication.php +++ b/app/Http/Middleware/RequireTwoFactorAuthentication.php @@ -51,7 +51,7 @@ public function handle(Request $request, \Closure $next): mixed // If the level is set as admin and the user is not an admin, pass them through as well. if ($level === self::LEVEL_NONE || $user->use_totp) { return $next($request); - } elseif ($level === self::LEVEL_ADMIN && !$user->root_admin) { + } elseif ($level === self::LEVEL_ADMIN && !$user->isRootAdmin()) { return $next($request); } diff --git a/app/Http/Requests/Admin/AdminFormRequest.php b/app/Http/Requests/Admin/AdminFormRequest.php index 766ac92a4b..54e9f5e92c 100644 --- a/app/Http/Requests/Admin/AdminFormRequest.php +++ b/app/Http/Requests/Admin/AdminFormRequest.php @@ -21,7 +21,7 @@ public function authorize(): bool return false; } - return (bool) $this->user()->root_admin; + return $this->user()->isRootAdmin(); } /** diff --git a/app/Http/Requests/Admin/NewUserFormRequest.php b/app/Http/Requests/Admin/NewUserFormRequest.php index db2f1ebe35..e3b91f597f 100644 --- a/app/Http/Requests/Admin/NewUserFormRequest.php +++ b/app/Http/Requests/Admin/NewUserFormRequest.php @@ -22,7 +22,6 @@ public function rules(): array 'name_last', 'password', 'language', - 'root_admin', ])->toArray(); } } diff --git a/app/Http/Requests/Admin/UserFormRequest.php b/app/Http/Requests/Admin/UserFormRequest.php index f9fc7e6796..78a0a1bbd2 100644 --- a/app/Http/Requests/Admin/UserFormRequest.php +++ b/app/Http/Requests/Admin/UserFormRequest.php @@ -22,7 +22,6 @@ public function rules(): array 'name_last', 'password', 'language', - 'root_admin', ])->toArray(); } } diff --git a/app/Http/Requests/Api/Application/Roles/DeleteRoleRequest.php b/app/Http/Requests/Api/Application/Roles/DeleteRoleRequest.php new file mode 100644 index 0000000000..43c005fe8f --- /dev/null +++ b/app/Http/Requests/Api/Application/Roles/DeleteRoleRequest.php @@ -0,0 +1,13 @@ + 'required|string', + 'guard_name' => 'nullable|string', + ]; + } +} diff --git a/app/Http/Requests/Api/Application/Roles/UpdateRoleRequest.php b/app/Http/Requests/Api/Application/Roles/UpdateRoleRequest.php new file mode 100644 index 0000000000..48dc3d04e9 --- /dev/null +++ b/app/Http/Requests/Api/Application/Roles/UpdateRoleRequest.php @@ -0,0 +1,7 @@ + 'array', + 'roles.*' => 'string', + ]; + } +} diff --git a/app/Http/Requests/Api/Application/Users/StoreUserRequest.php b/app/Http/Requests/Api/Application/Users/StoreUserRequest.php index 24e6e8940c..43603639c4 100644 --- a/app/Http/Requests/Api/Application/Users/StoreUserRequest.php +++ b/app/Http/Requests/Api/Application/Users/StoreUserRequest.php @@ -26,7 +26,6 @@ public function rules(array $rules = null): array 'password', 'language', 'timezone', - 'root_admin', ])->toArray(); $response['first_name'] = $rules['name_first']; @@ -56,7 +55,6 @@ public function attributes(): array 'external_id' => 'Third Party Identifier', 'name_first' => 'First Name', 'name_last' => 'Last Name', - 'root_admin' => 'Root Administrator Status', ]; } } diff --git a/app/Http/Requests/Api/Client/Servers/Subusers/SubuserRequest.php b/app/Http/Requests/Api/Client/Servers/Subusers/SubuserRequest.php index 7a4d52430b..bd68fb8908 100644 --- a/app/Http/Requests/Api/Client/Servers/Subusers/SubuserRequest.php +++ b/app/Http/Requests/Api/Client/Servers/Subusers/SubuserRequest.php @@ -56,7 +56,7 @@ protected function validatePermissionsCanBeAssigned(array $permissions) $server = $this->route()->parameter('server'); // If we are a root admin or the server owner, no need to perform these checks. - if ($user->root_admin || $user->id === $server->owner_id) { + if ($user->isRootAdmin() || $user->id === $server->owner_id) { return; } diff --git a/app/Models/Role.php b/app/Models/Role.php new file mode 100644 index 0000000000..1274b2d6c3 --- /dev/null +++ b/app/Models/Role.php @@ -0,0 +1,48 @@ + [ + 'import', + 'export', + ], + ]; + + public const SPECIAL_PERMISSIONS = [ + 'settings' => [ + 'view', + 'update', + ], + ]; + + public function isRootAdmin(): bool + { + return $this->name === self::ROOT_ADMIN; + } + + public static function getRootAdmin(): self + { + /** @var self $role */ + $role = self::findOrCreate(self::ROOT_ADMIN); + + return $role; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index fc96ed0f67..395f4994fa 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -25,6 +25,9 @@ use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract; use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; use App\Notifications\SendPasswordReset as ResetPasswordNotification; +use Filament\Facades\Filament; +use Illuminate\Database\Eloquent\Model as IlluminateModel; +use Spatie\Permission\Traits\HasRoles; /** * App\Models\User. @@ -40,7 +43,6 @@ * @property string|null $remember_token * @property string $language * @property string $timezone - * @property bool $root_admin * @property bool $use_totp * @property string|null $totp_secret * @property \Illuminate\Support\Carbon|null $totp_authenticated_at @@ -77,7 +79,6 @@ * @method static Builder|User whereNameLast($value) * @method static Builder|User wherePassword($value) * @method static Builder|User whereRememberToken($value) - * @method static Builder|User whereRootAdmin($value) * @method static Builder|User whereTotpAuthenticatedAt($value) * @method static Builder|User whereTotpSecret($value) * @method static Builder|User whereUpdatedAt($value) @@ -94,6 +95,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac use AvailableLanguages; use CanResetPassword; use HasAccessTokens; + use HasRoles; use Notifiable; public const USER_LEVEL_USER = 0; @@ -131,7 +133,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac 'totp_secret', 'totp_authenticated_at', 'gravatar', - 'root_admin', 'oauth', ]; @@ -145,7 +146,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac */ protected $attributes = [ 'external_id' => null, - 'root_admin' => false, 'language' => 'en', 'timezone' => 'UTC', 'use_totp' => false, @@ -166,7 +166,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac 'name_first' => 'nullable|string|between:0,255', 'name_last' => 'nullable|string|between:0,255', 'password' => 'sometimes|nullable|string', - 'root_admin' => 'boolean', 'language' => 'string', 'timezone' => 'string', 'use_totp' => 'boolean', @@ -177,7 +176,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac protected function casts(): array { return [ - 'root_admin' => 'boolean', 'use_totp' => 'boolean', 'gravatar' => 'boolean', 'totp_authenticated_at' => 'datetime', @@ -226,7 +224,10 @@ public static function getRules(): array */ public function toReactObject(): array { - return collect($this->toArray())->except(['id', 'external_id'])->toArray(); + return array_merge(collect($this->toArray())->except(['id', 'external_id'])->toArray(), [ + 'root_admin' => $this->isRootAdmin(), + 'admin' => $this->canAccessPanel(Filament::getPanel('admin')), + ]); } /** @@ -315,7 +316,7 @@ public function subusers(): HasMany protected function checkPermission(Server $server, string $permission = ''): bool { - if ($this->root_admin || $server->owner_id === $this->id) { + if ($this->isRootAdmin() || $server->owner_id === $this->id) { return true; } @@ -351,14 +352,23 @@ public function can($abilities, mixed $arguments = []): bool public function isLastRootAdmin(): bool { - $rootAdmins = User::query()->where('root_admin', true)->limit(2)->get(); + $rootAdmins = User::all()->filter(fn ($user) => $user->isRootAdmin()); return once(fn () => $rootAdmins->count() === 1 && $rootAdmins->first()->is($this)); } + public function isRootAdmin(): bool + { + return $this->hasRole(Role::ROOT_ADMIN); + } + public function canAccessPanel(Panel $panel): bool { - return $this->root_admin; + if ($this->isRootAdmin()) { + return true; + } + + return $this->roles()->count() >= 1 && $this->getAllPermissions()->count() >= 1; } public function getFilamentName(): string @@ -370,4 +380,13 @@ public function getFilamentAvatarUrl(): ?string { return 'https://gravatar.com/avatar/' . md5(strtolower($this->email)); } + + public function canTarget(IlluminateModel $user): bool + { + if ($this->isRootAdmin()) { + return true; + } + + return $user instanceof User && !$user->isRootAdmin(); + } } diff --git a/app/Policies/ApiKeyPolicy.php b/app/Policies/ApiKeyPolicy.php new file mode 100644 index 0000000000..37aac90939 --- /dev/null +++ b/app/Policies/ApiKeyPolicy.php @@ -0,0 +1,10 @@ +can('viewList ' . $this->modelName); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Model $model): bool + { + return $user->can('view ' . $this->modelName, $model); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->can('create ' . $this->modelName); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Model $model): bool + { + return $user->can('update ' . $this->modelName, $model); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Model $model): bool + { + return $user->can('delete ' . $this->modelName, $model); + } +} diff --git a/app/Policies/EggPolicy.php b/app/Policies/EggPolicy.php index 856eeedf62..bd589f1b25 100644 --- a/app/Policies/EggPolicy.php +++ b/app/Policies/EggPolicy.php @@ -2,12 +2,9 @@ namespace App\Policies; -use App\Models\User; - class EggPolicy { - public function create(User $user): bool - { - return true; - } + use DefaultPolicies; + + protected string $modelName = 'egg'; } diff --git a/app/Policies/MountPolicy.php b/app/Policies/MountPolicy.php new file mode 100644 index 0000000000..4f9d58b63b --- /dev/null +++ b/app/Policies/MountPolicy.php @@ -0,0 +1,10 @@ +subusers->where('user_id', $user->id)->first(); - if (!$subuser || empty($permission)) { - return false; - } + use DefaultPolicies; - return in_array($permission, $subuser->permissions); - } + protected string $modelName = 'server'; /** - * Runs before any of the functions are called. Used to determine if user is root admin, if so, ignore permissions. + * Runs before any of the functions are called. Used to determine if the (sub-)user has permissions. */ - public function before(User $user, string $ability, Server $server): bool + public function before(User $user, string $ability, string|Server $server): ?bool { - if ($user->root_admin || $server->owner_id === $user->id) { + // For "viewAny" the $server param is the class name + if (is_string($server)) { + return null; + } + + // Owner has full server permissions + if ($server->owner_id === $user->id) { return true; } - return $this->checkPermission($user, $server, $ability); + $subuser = $server->subusers->where('user_id', $user->id)->first(); + // If the user is a subuser check their permissions + if ($subuser) { + return in_array($ability, $subuser->permissions); + } + + // Return null to let default policies take over + return null; } /** diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php new file mode 100644 index 0000000000..6f975b474d --- /dev/null +++ b/app/Policies/UserPolicy.php @@ -0,0 +1,26 @@ +canTarget($model) && $this->defaultUpdate($user, $model); + } + + public function delete(User $user, Model $model): bool + { + return $user->canTarget($model) && $this->defaultDelete($user, $model); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 19a43bef47..20a6b0dd51 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -6,6 +6,7 @@ use App\Models; use App\Models\ApiKey; use App\Models\Node; +use App\Models\User; use App\Services\Helpers\SoftwareVersionService; use Dedoc\Scramble\Scramble; use Dedoc\Scramble\Support\Generator\OpenApi; @@ -91,6 +92,10 @@ public function boot(): void 'success' => Color::Green, 'warning' => Color::Amber, ]); + + Gate::before(function (User $user, $ability) { + return $user->isRootAdmin() ? true : null; + }); } /** diff --git a/app/Services/Acl/Api/AdminAcl.php b/app/Services/Acl/Api/AdminAcl.php index 3a0e4961c1..dd58e317c6 100644 --- a/app/Services/Acl/Api/AdminAcl.php +++ b/app/Services/Acl/Api/AdminAcl.php @@ -32,6 +32,7 @@ class AdminAcl public const RESOURCE_DATABASE_HOSTS = 'database_hosts'; public const RESOURCE_SERVER_DATABASES = 'server_databases'; public const RESOURCE_MOUNTS = 'mounts'; + public const RESOURCE_ROLES = 'roles'; /** * Determine if an API key has permission to perform a specific read/write operation. diff --git a/app/Services/Servers/GetUserPermissionsService.php b/app/Services/Servers/GetUserPermissionsService.php index 8c58c23f62..882f8b7cc7 100644 --- a/app/Services/Servers/GetUserPermissionsService.php +++ b/app/Services/Servers/GetUserPermissionsService.php @@ -14,10 +14,10 @@ class GetUserPermissionsService */ public function handle(Server $server, User $user): array { - if ($user->root_admin || $user->id === $server->owner_id) { + if ($user->isRootAdmin() || $user->id === $server->owner_id) { $permissions = ['*']; - if ($user->root_admin) { + if ($user->isRootAdmin()) { $permissions[] = 'admin.websocket.errors'; $permissions[] = 'admin.websocket.install'; $permissions[] = 'admin.websocket.transfer'; diff --git a/app/Services/Users/UserCreationService.php b/app/Services/Users/UserCreationService.php index 2f53696031..958d84442b 100644 --- a/app/Services/Users/UserCreationService.php +++ b/app/Services/Users/UserCreationService.php @@ -2,6 +2,7 @@ namespace App\Services\Users; +use App\Models\Role; use Ramsey\Uuid\Uuid; use App\Models\User; use Illuminate\Contracts\Hashing\Hasher; @@ -39,10 +40,17 @@ public function handle(array $data): User $data['password'] = $this->hasher->make(str_random(30)); } + $isRootAdmin = array_key_exists('root_admin', $data) && $data['root_admin']; + unset($data['root_admin']); + $user = User::query()->forceCreate(array_merge($data, [ 'uuid' => Uuid::uuid4()->toString(), ])); + if ($isRootAdmin) { + $user->syncRoles(Role::getRootAdmin()); + } + if (isset($generateResetToken)) { $token = $this->passwordBroker->createToken($user); } diff --git a/app/Transformers/Api/Application/BaseTransformer.php b/app/Transformers/Api/Application/BaseTransformer.php index 939266f82f..62ee4ceeb6 100644 --- a/app/Transformers/Api/Application/BaseTransformer.php +++ b/app/Transformers/Api/Application/BaseTransformer.php @@ -77,7 +77,7 @@ protected function authorize(string $resource): bool // the user is a root admin at the moment. In a future release we'll be rolling // out more specific permissions for keys. if ($token->key_type === ApiKey::TYPE_ACCOUNT) { - return $this->request->user()->root_admin; + return $this->request->user()->isRootAdmin(); } return AdminAcl::check($token, $resource); diff --git a/app/Transformers/Api/Application/RolePermissionTransformer.php b/app/Transformers/Api/Application/RolePermissionTransformer.php new file mode 100644 index 0000000000..968a54f8fc --- /dev/null +++ b/app/Transformers/Api/Application/RolePermissionTransformer.php @@ -0,0 +1,23 @@ + $model->name, + 'guard_name' => $model->guard_name, + 'created_at' => $model->created_at->toAtomString(), + 'updated_at' => $model->updated_at->toAtomString(), + ]; + } +} diff --git a/app/Transformers/Api/Application/RoleTransformer.php b/app/Transformers/Api/Application/RoleTransformer.php new file mode 100644 index 0000000000..d77b04e570 --- /dev/null +++ b/app/Transformers/Api/Application/RoleTransformer.php @@ -0,0 +1,47 @@ + $model->name, + 'guard_name' => $model->guard_name, + 'created_at' => $model->created_at->toAtomString(), + 'updated_at' => $model->updated_at->toAtomString(), + ]; + } + + /** + * Include the permissions associated with this role. + * + * @throws \App\Exceptions\Transformer\InvalidTransformerLevelException + */ + public function includePermissions(Role $model): Collection|NullResource + { + $model->loadMissing('permissions'); + + return $this->collection($model->getRelation('permissions'), $this->makeTransformer(RolePermissionTransformer::class), 'permissions'); + } +} diff --git a/app/Transformers/Api/Application/UserTransformer.php b/app/Transformers/Api/Application/UserTransformer.php index ddec17e82b..5cf800c08a 100644 --- a/app/Transformers/Api/Application/UserTransformer.php +++ b/app/Transformers/Api/Application/UserTransformer.php @@ -2,6 +2,7 @@ namespace App\Transformers\Api\Application; +use App\Models\Role; use App\Models\User; use League\Fractal\Resource\Collection; use League\Fractal\Resource\NullResource; @@ -12,7 +13,10 @@ class UserTransformer extends BaseTransformer /** * List of resources that can be included. */ - protected array $availableIncludes = ['servers']; + protected array $availableIncludes = [ + 'servers', + 'roles', + ]; /** * Return the resource name for the JSONAPI output. @@ -36,7 +40,7 @@ public function transform(User $user): array 'first_name' => $user->name_first, 'last_name' => $user->name_last, 'language' => $user->language, - 'root_admin' => (bool) $user->root_admin, + 'root_admin' => $user->isRootAdmin(), '2fa_enabled' => (bool) $user->use_totp, '2fa' => (bool) $user->use_totp, // deprecated, use "2fa_enabled" 'created_at' => $this->formatTimestamp($user->created_at), @@ -59,4 +63,20 @@ public function includeServers(User $user): Collection|NullResource return $this->collection($user->getRelation('servers'), $this->makeTransformer(ServerTransformer::class), 'server'); } + + /** + * Return the roles associated with this user. + * + * @throws \App\Exceptions\Transformer\InvalidTransformerLevelException + */ + public function includeRoles(User $user): Collection|NullResource + { + if (!$this->authorize(AdminAcl::RESOURCE_ROLES)) { + return $this->null(); + } + + $user->loadMissing('roles'); + + return $this->collection($user->getRelation('roles'), $this->makeTransformer(RoleTransformer::class), Role::RESOURCE_NAME); + } } diff --git a/app/Transformers/Api/Client/ActivityLogTransformer.php b/app/Transformers/Api/Client/ActivityLogTransformer.php index 488ad1c954..58d5f13ae5 100644 --- a/app/Transformers/Api/Client/ActivityLogTransformer.php +++ b/app/Transformers/Api/Client/ActivityLogTransformer.php @@ -113,6 +113,6 @@ protected function hasAdditionalMetadata(ActivityLog $model): bool */ protected function canViewIP(Model $actor = null): bool { - return $actor?->is($this->request->user()) || $this->request->user()->root_admin; + return $actor?->is($this->request->user()) || $this->request->user()->isRootAdmin(); } } diff --git a/app/Transformers/Api/Client/UserTransformer.php b/app/Transformers/Api/Client/UserTransformer.php index 6b42ae507a..f875463e56 100644 --- a/app/Transformers/Api/Client/UserTransformer.php +++ b/app/Transformers/Api/Client/UserTransformer.php @@ -29,8 +29,8 @@ public function transform(User $user): array 'last_name' => $user->name_last, 'language' => $user->language, 'image' => 'https://gravatar.com/avatar/' . md5(Str::lower($user->email)), // deprecated - 'admin' => (bool) $user->root_admin, // deprecated, use "root_admin" - 'root_admin' => (bool) $user->root_admin, + 'admin' => $user->isRootAdmin(), // deprecated, use "root_admin" + 'root_admin' => $user->isRootAdmin(), '2fa_enabled' => (bool) $user->use_totp, 'created_at' => $this->formatTimestamp($user->created_at), 'updated_at' => $this->formatTimestamp($user->updated_at), diff --git a/composer.json b/composer.json index aaa3e9bc51..7e8ac8a0c7 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,7 @@ "s1lentium/iptools": "~1.2.0", "socialiteproviders/discord": "^4.2", "spatie/laravel-fractal": "^6.2", + "spatie/laravel-permission": "^6.9", "spatie/laravel-query-builder": "^5.8.1", "spatie/temporary-directory": "^2.2", "symfony/http-client": "^7.1", diff --git a/composer.lock b/composer.lock index 22543ba079..c8e3fa55a7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "443ec1d95b892b261af5481f27b31083", + "content-hash": "507ac5b637c51b90e6ae00717fe085cc", "packages": [ { "name": "abdelhamiderrahmouni/filament-monaco-editor", @@ -7234,6 +7234,88 @@ ], "time": "2024-03-20T07:29:11+00:00" }, + { + "name": "spatie/laravel-permission", + "version": "6.9.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-permission.git", + "reference": "fe973a58b44380d0e8620107259b7bda22f70408" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/fe973a58b44380d0e8620107259b7bda22f70408", + "reference": "fe973a58b44380d0e8620107259b7bda22f70408", + "shasum": "" + }, + "require": { + "illuminate/auth": "^8.12|^9.0|^10.0|^11.0", + "illuminate/container": "^8.12|^9.0|^10.0|^11.0", + "illuminate/contracts": "^8.12|^9.0|^10.0|^11.0", + "illuminate/database": "^8.12|^9.0|^10.0|^11.0", + "php": "^8.0" + }, + "require-dev": { + "laravel/passport": "^11.0|^12.0", + "orchestra/testbench": "^6.23|^7.0|^8.0|^9.0", + "phpunit/phpunit": "^9.4|^10.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.x-dev", + "dev-master": "6.x-dev" + }, + "laravel": { + "providers": [ + "Spatie\\Permission\\PermissionServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Permission\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Permission handling for Laravel 8.0 and up", + "homepage": "https://github.com/spatie/laravel-permission", + "keywords": [ + "acl", + "laravel", + "permission", + "permissions", + "rbac", + "roles", + "security", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-permission/issues", + "source": "https://github.com/spatie/laravel-permission/tree/6.9.0" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2024-06-22T23:04:52+00:00" + }, { "name": "spatie/laravel-query-builder", "version": "5.8.1", diff --git a/config/permission.php b/config/permission.php new file mode 100644 index 0000000000..4d4b6e16f6 --- /dev/null +++ b/config/permission.php @@ -0,0 +1,13 @@ + [ + + 'permission' => Spatie\Permission\Models\Permission::class, + + 'role' => \App\Models\Role::class, + + ], + +]; diff --git a/database/Factories/UserFactory.php b/database/Factories/UserFactory.php index 510edaef1e..3c6003cc24 100644 --- a/database/Factories/UserFactory.php +++ b/database/Factories/UserFactory.php @@ -33,19 +33,10 @@ public function definition(): array 'name_last' => $this->faker->lastName(), 'password' => $password ?: $password = bcrypt('password'), 'language' => 'en', - 'root_admin' => false, 'use_totp' => false, 'oauth' => [], 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), ]; } - - /** - * Indicate that the user is an admin. - */ - public function admin(): static - { - return $this->state(['root_admin' => true]); - } } diff --git a/database/Seeders/DatabaseSeeder.php b/database/Seeders/DatabaseSeeder.php index 3a67335f81..2f7f6694e1 100644 --- a/database/Seeders/DatabaseSeeder.php +++ b/database/Seeders/DatabaseSeeder.php @@ -2,6 +2,7 @@ namespace Database\Seeders; +use App\Models\Role; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder @@ -12,5 +13,7 @@ class DatabaseSeeder extends Seeder public function run() { $this->call(EggSeeder::class); + + Role::firstOrCreate(['name' => Role::ROOT_ADMIN]); } } diff --git a/database/migrations/2024_07_19_130942_create_permission_tables.php b/database/migrations/2024_07_19_130942_create_permission_tables.php new file mode 100644 index 0000000000..9c7044b46e --- /dev/null +++ b/database/migrations/2024_07_19_130942_create_permission_tables.php @@ -0,0 +1,140 @@ +engine('InnoDB'); + $table->bigIncrements('id'); // permission id + $table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format) + $table->string('guard_name'); // For MyISAM use string('guard_name', 25); + $table->timestamps(); + + $table->unique(['name', 'guard_name']); + }); + + Schema::create($tableNames['roles'], function (Blueprint $table) use ($teams, $columnNames) { + //$table->engine('InnoDB'); + $table->bigIncrements('id'); // role id + if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing + $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable(); + $table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index'); + } + $table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format) + $table->string('guard_name'); // For MyISAM use string('guard_name', 25); + $table->timestamps(); + if ($teams || config('permission.testing')) { + $table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']); + } else { + $table->unique(['name', 'guard_name']); + } + }); + + Schema::create($tableNames['model_has_permissions'], function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) { + $table->unsignedBigInteger($pivotPermission); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index'); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->onDelete('cascade'); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } else { + $table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } + + }); + + Schema::create($tableNames['model_has_roles'], function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) { + $table->unsignedBigInteger($pivotRole); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index'); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->onDelete('cascade'); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } else { + $table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } + }); + + Schema::create($tableNames['role_has_permissions'], function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) { + $table->unsignedBigInteger($pivotPermission); + $table->unsignedBigInteger($pivotRole); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->onDelete('cascade'); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->onDelete('cascade'); + + $table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary'); + }); + + app('cache') + ->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null) + ->forget(config('permission.cache.key')); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $tableNames = config('permission.table_names'); + + if (empty($tableNames)) { + throw new \Exception('Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.'); + } + + Schema::drop($tableNames['role_has_permissions']); + Schema::drop($tableNames['model_has_roles']); + Schema::drop($tableNames['model_has_permissions']); + Schema::drop($tableNames['roles']); + Schema::drop($tableNames['permissions']); + } +}; diff --git a/database/migrations/2024_08_01_114538_remove_root_admin_column.php b/database/migrations/2024_08_01_114538_remove_root_admin_column.php new file mode 100644 index 0000000000..128063ed16 --- /dev/null +++ b/database/migrations/2024_08_01_114538_remove_root_admin_column.php @@ -0,0 +1,35 @@ +get(); + foreach ($adminUsers as $adminUser) { + $adminUser->syncRoles(Role::getRootAdmin()); + } + + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('root_admin'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->tinyInteger('root_admin')->unsigned()->default(0); + }); + } +}; diff --git a/lang/en/admin/user.php b/lang/en/admin/user.php index 2fb03e00d6..2bf52b77af 100644 --- a/lang/en/admin/user.php +++ b/lang/en/admin/user.php @@ -13,7 +13,6 @@ 'hint' => 'This is the last root administrator!', 'helper_text' => 'You must have at least one root administrator in your system.', ], - 'root_admin' => 'Administrator (Root)', 'language' => [ 'helper_text1' => 'Your language (:state) has not been translated yet!\nBut never fear, you can help fix that by', 'helper_text2' => 'contributing directly here', diff --git a/resources/scripts/components/App.tsx b/resources/scripts/components/App.tsx index 881ff4c013..8a211b4633 100644 --- a/resources/scripts/components/App.tsx +++ b/resources/scripts/components/App.tsx @@ -27,6 +27,7 @@ interface ExtendedWindow extends Window { email: string; /* eslint-disable camelcase */ root_admin: boolean; + admin: boolean; use_totp: boolean; language: string; updated_at: string; @@ -46,6 +47,7 @@ const App = () => { email: PanelUser.email, language: PanelUser.language, rootAdmin: PanelUser.root_admin, + admin: PanelUser.admin, useTotp: PanelUser.use_totp, createdAt: new Date(PanelUser.created_at), updatedAt: new Date(PanelUser.updated_at), diff --git a/resources/scripts/components/NavigationBar.tsx b/resources/scripts/components/NavigationBar.tsx index f784ddfff6..7372ccbeed 100644 --- a/resources/scripts/components/NavigationBar.tsx +++ b/resources/scripts/components/NavigationBar.tsx @@ -37,7 +37,7 @@ export default () => { const { t } = useTranslation('strings'); const name = useStoreState((state: ApplicationStore) => state.settings.data!.name); - const rootAdmin = useStoreState((state: ApplicationStore) => state.user.data!.rootAdmin); + const isAdmin = useStoreState((state: ApplicationStore) => state.user.data!.admin); const [isLoggingOut, setIsLoggingOut] = useState(false); const onTriggerLogout = () => { @@ -69,7 +69,7 @@ export default () => { - {rootAdmin && ( + {isAdmin && ( ('admin')}> diff --git a/resources/scripts/state/user.ts b/resources/scripts/state/user.ts index 0ec7851377..76ba5b92f9 100644 --- a/resources/scripts/state/user.ts +++ b/resources/scripts/state/user.ts @@ -7,6 +7,7 @@ export interface UserData { email: string; language: string; rootAdmin: boolean; + admin: boolean; useTotp: boolean; createdAt: Date; updatedAt: Date; diff --git a/routes/api-application.php b/routes/api-application.php index c213c74ab3..cf5cbb9dfc 100644 --- a/routes/api-application.php +++ b/routes/api-application.php @@ -19,6 +19,8 @@ Route::post('/', [Application\Users\UserController::class, 'store']); Route::patch('/{user:id}', [Application\Users\UserController::class, 'update']); + Route::patch('/{user:id}/roles', [Application\Users\UserController::class, 'roles']); + Route::delete('/{user:id}', [Application\Users\UserController::class, 'delete']); }); @@ -141,3 +143,22 @@ Route::delete('/{mount:id}/eggs/{egg_id}', [Application\Mounts\MountController::class, 'deleteEgg']); Route::delete('/{mount:id}/nodes/{node_id}', [Application\Mounts\MountController::class, 'deleteNode']); }); + +/* +|-------------------------------------------------------------------------- +| Role Controller Routes +|-------------------------------------------------------------------------- +| +| Endpoint: /api/application/roles +| +*/ +Route::group(['prefix' => '/roles'], function () { + Route::get('/', [Application\Roles\RoleController::class, 'index'])->name('api.application.roles'); + Route::get('/{role:id}', [Application\Roles\RoleController::class, 'view'])->name('api.application.roles.view'); + + Route::post('/', [Application\Roles\RoleController::class, 'store']); + + Route::patch('/{role:id}', [Application\Roles\RoleController::class, 'update']); + + Route::delete('/{role:id}', [Application\Roles\RoleController::class, 'delete']); +}); diff --git a/tests/Integration/Api/Application/ApplicationApiIntegrationTestCase.php b/tests/Integration/Api/Application/ApplicationApiIntegrationTestCase.php index e4fe09570b..c104a3eae9 100644 --- a/tests/Integration/Api/Application/ApplicationApiIntegrationTestCase.php +++ b/tests/Integration/Api/Application/ApplicationApiIntegrationTestCase.php @@ -6,6 +6,7 @@ use App\Models\User; use PHPUnit\Framework\Assert; use App\Models\ApiKey; +use App\Models\Role; use App\Services\Acl\Api\AdminAcl; use App\Tests\Integration\IntegrationTestCase; use Illuminate\Foundation\Testing\DatabaseTransactions; @@ -67,9 +68,10 @@ protected function createNewDefaultApiKey(User $user, array $permissions = []): */ protected function createApiUser(): User { - return User::factory()->create([ - 'root_admin' => true, - ]); + $user = User::factory()->create(); + $user->syncRoles(Role::getRootAdmin()); + + return $user; } /** diff --git a/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php b/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php index 26506b7a50..24f3996ebf 100644 --- a/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php +++ b/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php @@ -38,7 +38,7 @@ public function testGetRemoteUser(): void 'first_name' => $user->name_first, 'last_name' => $user->name_last, 'language' => $user->language, - 'root_admin' => (bool) $user->root_admin, + 'root_admin' => (bool) $user->isRootAdmin(), '2fa' => (bool) $user->totp_enabled, 'created_at' => $this->formatTimestamp($user->created_at), 'updated_at' => $this->formatTimestamp($user->updated_at), diff --git a/tests/Integration/Api/Application/Users/UserControllerTest.php b/tests/Integration/Api/Application/Users/UserControllerTest.php index b52c1c2c67..6787ff1f4e 100644 --- a/tests/Integration/Api/Application/Users/UserControllerTest.php +++ b/tests/Integration/Api/Application/Users/UserControllerTest.php @@ -55,7 +55,7 @@ public function testGetUsers(): void 'first_name' => $this->getApiUser()->name_first, 'last_name' => $this->getApiUser()->name_last, 'language' => $this->getApiUser()->language, - 'root_admin' => $this->getApiUser()->root_admin, + 'root_admin' => $this->getApiUser()->isRootAdmin(), '2fa_enabled' => (bool) $this->getApiUser()->totp_enabled, '2fa' => (bool) $this->getApiUser()->totp_enabled, 'created_at' => $this->formatTimestamp($this->getApiUser()->created_at), @@ -73,7 +73,7 @@ public function testGetUsers(): void 'first_name' => $user->name_first, 'last_name' => $user->name_last, 'language' => $user->language, - 'root_admin' => (bool) $user->root_admin, + 'root_admin' => (bool) $user->isRootAdmin(), '2fa_enabled' => (bool) $user->totp_enabled, '2fa' => (bool) $user->totp_enabled, 'created_at' => $this->formatTimestamp($user->created_at), diff --git a/tests/Integration/Api/Client/ClientControllerTest.php b/tests/Integration/Api/Client/ClientControllerTest.php index 29805711f5..e4059f6947 100644 --- a/tests/Integration/Api/Client/ClientControllerTest.php +++ b/tests/Integration/Api/Client/ClientControllerTest.php @@ -7,6 +7,7 @@ use App\Models\Subuser; use App\Models\Allocation; use App\Models\Permission; +use App\Models\Role; class ClientControllerTest extends ClientApiIntegrationTestCase { @@ -47,7 +48,7 @@ public function testServersAreFilteredUsingNameAndUuidInformation(): void { /** @var \App\Models\User[] $users */ $users = User::factory()->times(2)->create(); - $users[0]->update(['root_admin' => true]); + $users[0]->syncRoles(Role::getRootAdmin()); /** @var \App\Models\Server[] $servers */ $servers = [ @@ -225,7 +226,7 @@ public function testOnlyAdminLevelServersAreReturned(): void { /** @var \App\Models\User[] $users */ $users = User::factory()->times(4)->create(); - $users[0]->update(['root_admin' => true]); + $users[0]->syncRoles(Role::getRootAdmin()); $servers = [ $this->createServerModel(['user_id' => $users[0]->id]), @@ -260,7 +261,7 @@ public function testAllServersAreReturnedToAdmin(): void { /** @var \App\Models\User[] $users */ $users = User::factory()->times(4)->create(); - $users[0]->update(['root_admin' => true]); + $users[0]->syncRoles(Role::getRootAdmin()); $servers = [ $this->createServerModel(['user_id' => $users[0]->id]), diff --git a/tests/Integration/Api/Remote/SftpAuthenticationControllerTest.php b/tests/Integration/Api/Remote/SftpAuthenticationControllerTest.php index 2affbef733..031eedce70 100644 --- a/tests/Integration/Api/Remote/SftpAuthenticationControllerTest.php +++ b/tests/Integration/Api/Remote/SftpAuthenticationControllerTest.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Models\Server; use App\Models\Permission; +use App\Models\Role; use App\Models\UserSSHKey; use App\Tests\Integration\IntegrationTestCase; @@ -180,7 +181,7 @@ public function testUserPermissionsAreReturnedCorrectly(): void ->assertOk() ->assertJsonPath('permissions', [Permission::ACTION_FILE_READ, Permission::ACTION_FILE_SFTP]); - $user->update(['root_admin' => true]); + $user->syncRoles(Role::getRootAdmin()); $this->postJson('/api/remote/sftp/auth', $data) ->assertOk() @@ -193,7 +194,7 @@ public function testUserPermissionsAreReturnedCorrectly(): void ->assertOk() ->assertJsonPath('permissions.0', '*'); - $user->update(['root_admin' => false]); + $user->syncRoles(); $this->post('/api/remote/sftp/auth', $data)->assertForbidden(); } diff --git a/tests/TestCase.php b/tests/TestCase.php index fabaa7762d..abcd9d84d8 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -5,6 +5,7 @@ use Carbon\Carbon; use Carbon\CarbonImmutable; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; +use Spatie\Permission\PermissionRegistrar; abstract class TestCase extends BaseTestCase { @@ -28,6 +29,8 @@ protected function setUp(): void config()->set('app.debug', false); $this->setKnownUuidFactory(); + + $this->app->make(PermissionRegistrar::class)->forgetCachedPermissions(); } /** diff --git a/tests/Traits/Http/RequestMockHelpers.php b/tests/Traits/Http/RequestMockHelpers.php index a0564ea491..22f60e74f6 100644 --- a/tests/Traits/Http/RequestMockHelpers.php +++ b/tests/Traits/Http/RequestMockHelpers.php @@ -35,13 +35,14 @@ public function setRequestUserModel(User $user = null): void /** * Generates a new request user model and also returns the generated model. */ - public function generateRequestUserModel(array $args = []): User + public function generateRequestUserModel(bool $isRootAdmin, array $args = []): void { - /** @var \App\Models\User $user */ $user = User::factory()->make($args); - $this->setRequestUserModel($user); + $user = m::mock($user)->makePartial(); + $user->shouldReceive('isRootAdmin')->andReturn($isRootAdmin); - return $user; + /** @var User|Mock $user */ + $this->setRequestUserModel($user); } /** diff --git a/tests/Unit/Http/Middleware/AdminAuthenticateTest.php b/tests/Unit/Http/Middleware/AdminAuthenticateTest.php index 87f647819b..40b15775e8 100644 --- a/tests/Unit/Http/Middleware/AdminAuthenticateTest.php +++ b/tests/Unit/Http/Middleware/AdminAuthenticateTest.php @@ -4,6 +4,7 @@ use App\Models\User; use App\Http\Middleware\AdminAuthenticate; +use Mockery; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; class AdminAuthenticateTest extends MiddlewareTestCase @@ -13,7 +14,9 @@ class AdminAuthenticateTest extends MiddlewareTestCase */ public function testAdminsAreAuthenticated(): void { - $user = User::factory()->make(['root_admin' => 1]); + $user = User::factory()->make(); + $user = Mockery::mock($user)->makePartial(); + $user->shouldReceive('isRootAdmin')->andReturnTrue(); $this->request->shouldReceive('user')->withNoArgs()->twice()->andReturn($user); @@ -39,7 +42,9 @@ public function testExceptionIsThrownIfUserIsNotAnAdmin(): void { $this->expectException(AccessDeniedHttpException::class); - $user = User::factory()->make(['root_admin' => 0]); + $user = User::factory()->make(); + $user = Mockery::mock($user)->makePartial(); + $user->shouldReceive('isRootAdmin')->andReturnFalse(); $this->request->shouldReceive('user')->withNoArgs()->twice()->andReturn($user); diff --git a/tests/Unit/Http/Middleware/Api/Application/AuthenticateUserTest.php b/tests/Unit/Http/Middleware/Api/Application/AuthenticateUserTest.php index bfaf4cd75e..6b0bdd2775 100644 --- a/tests/Unit/Http/Middleware/Api/Application/AuthenticateUserTest.php +++ b/tests/Unit/Http/Middleware/Api/Application/AuthenticateUserTest.php @@ -27,7 +27,7 @@ public function testNonAdminUser(): void { $this->expectException(AccessDeniedHttpException::class); - $this->generateRequestUserModel(['root_admin' => false]); + $this->generateRequestUserModel(false); $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); } @@ -37,7 +37,7 @@ public function testNonAdminUser(): void */ public function testAdminUser(): void { - $this->generateRequestUserModel(['root_admin' => true]); + $this->generateRequestUserModel(true); $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); }