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: 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 @@ +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'); + } + }); } } diff --git a/app/Filament/Pages/Settings.php b/app/Filament/Pages/Settings.php index ad9ea96bae..4c766a53ee 100644 --- a/app/Filament/Pages/Settings.php +++ b/app/Filament/Pages/Settings.php @@ -49,12 +49,18 @@ public function mount(): void $this->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') @@ -86,6 +92,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') @@ -146,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', @@ -225,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) @@ -274,7 +284,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') @@ -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 9f2de64b92..8264eb9f55 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; @@ -82,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() @@ -99,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); @@ -322,9 +306,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') @@ -334,7 +318,7 @@ public function form(Form $form): Form 'default' => 1, 'sm' => 2, 'md' => 2, - 'lg' => 3, + 'lg' => 4, ]) ->searchable() ->preload() @@ -391,29 +375,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') @@ -444,7 +450,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 +477,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 +812,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 +829,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:') diff --git a/app/Filament/Resources/ServerResource/Pages/EditServer.php b/app/Filament/Resources/ServerResource/Pages/EditServer.php index aa98cc9192..3765a50522 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 @@ -469,7 +470,21 @@ public function form(Form $form): Form }), 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) { 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/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; 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 f2abd802cb..96f7043602 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -28,6 +28,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. @@ -43,7 +46,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 @@ -80,7 +82,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) @@ -97,6 +98,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac use AvailableLanguages; use CanResetPassword; use HasAccessTokens; + use HasRoles; use Notifiable; public const USER_LEVEL_USER = 0; @@ -134,7 +136,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac 'totp_secret', 'totp_authenticated_at', 'gravatar', - 'root_admin', 'oauth', ]; @@ -148,7 +149,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac */ protected $attributes = [ 'external_id' => null, - 'root_admin' => false, 'language' => 'en', 'timezone' => 'UTC', 'use_totp' => false, @@ -169,7 +169,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', @@ -180,7 +179,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', @@ -229,7 +227,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')), + ]); } /** @@ -323,7 +324,7 @@ public function subServers(): BelongsToMany 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; } @@ -359,11 +360,29 @@ 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 + { + if ($this->isRootAdmin()) { + return true; + } + + if ($panel->getId() === 'admin') { + return $this->roles()->count() >= 1 && $this->getAllPermissions()->count() >= 1; + } + + return true; + } + public function getFilamentName(): string { return $this->name_first ?: $this->username; @@ -374,13 +393,13 @@ public function getFilamentAvatarUrl(): ?string return 'https://gravatar.com/avatar/' . md5(strtolower($this->email)); } - public function canAccessPanel(Panel $panel): bool + public function canTarget(IlluminateModel $user): bool { - if ($panel->getId() === 'admin') { - return $this->root_admin; + if ($this->isRootAdmin()) { + return true; } - return true; + return $user instanceof User && !$user->isRootAdmin(); } public function getTenants(Panel $panel): array|Collection @@ -388,10 +407,10 @@ public function getTenants(Panel $panel): array|Collection return $this->accessibleServers()->get(); } - public function canAccessTenant(\Illuminate\Database\Eloquent\Model $tenant): bool + public function canAccessTenant(IlluminateModel $tenant): bool { if ($tenant instanceof Server) { - if ($this->root_admin || $tenant->owner_id === $this->id) { + if ($this->isRootAdmin() || $tenant->owner_id === $this->id) { return true; } 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 1f70a446d3..81bead5074 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,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 9d8356321a..cc8fd7801b 100644 --- a/composer.lock +++ b/composer.lock @@ -7288,6 +7288,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/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 +} 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_07_25_072050_convert_rules_to_array.php b/database/migrations/2024_07_25_072050_convert_rules_to_array.php index 8e6473a950..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(); + }); } /** @@ -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(); }); 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 9a5b3556c3..63f49f6f89 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 = () => { @@ -74,7 +74,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()); }