diff --git a/app/Console/Commands/User/MakeUserCommand.php b/app/Console/Commands/User/MakeUserCommand.php
index 20de2ec20..320f07f53 100644
--- a/app/Console/Commands/User/MakeUserCommand.php
+++ b/app/Console/Commands/User/MakeUserCommand.php
@@ -52,7 +52,7 @@ class MakeUserCommand extends Command
['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 000000000..0a80d5a56
--- /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 @@ class Settings extends Page implements HasForms
->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 @@ class Settings extends Page implements HasForms
->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 @@ class Settings extends Page implements HasForms
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 9307488f8..18c4b2fd7 100644
--- a/app/Filament/Resources/DatabaseHostResource/Pages/ListDatabaseHosts.php
+++ b/app/Filament/Resources/DatabaseHostResource/Pages/ListDatabaseHosts.php
@@ -42,7 +42,8 @@ class ListDatabaseHosts extends ListRecords
])
->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 45df4a597..6dfe7762a 100644
--- a/app/Filament/Resources/DatabaseResource/Pages/ListDatabases.php
+++ b/app/Filament/Resources/DatabaseResource/Pages/ListDatabases.php
@@ -4,10 +4,10 @@ namespace App\Filament\Resources\DatabaseResource\Pages;
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 @@ class ListDatabases extends ListRecords
])
->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 ee7bf5bd6..212aec8f2 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\TagsInput;
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 @@ class EditEgg extends EditRecord
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 @@ class EditEgg extends EditRecord
->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 947d2231a..1a6a3b650 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\Tabs\Tab;
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 @@ class ListEggs extends ListRecords
->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 @@ class ListEggs extends ListRecords
->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 ea3970df5..d39c5d972 100644
--- a/app/Filament/Resources/MountResource/Pages/ListMounts.php
+++ b/app/Filament/Resources/MountResource/Pages/ListMounts.php
@@ -43,7 +43,8 @@ class ListMounts extends ListRecords
])
->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 6ecb6b690..62f0254b8 100644
--- a/app/Filament/Resources/NodeResource/Pages/ListNodes.php
+++ b/app/Filament/Resources/NodeResource/Pages/ListNodes.php
@@ -84,7 +84,8 @@ class ListNodes extends ListRecords
])
->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 d30df8445..01cc5e50e 100644
--- a/app/Filament/Resources/NodeResource/RelationManagers/AllocationsRelationManager.php
+++ b/app/Filament/Resources/NodeResource/RelationManagers/AllocationsRelationManager.php
@@ -7,12 +7,12 @@ use App\Models\Node;
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 @@ class AllocationsRelationManager extends RelationManager
])
->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 000000000..306c9676d
--- /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 000000000..2f69bba01
--- /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 000000000..c62e12691
--- /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 000000000..ac83be15d
--- /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 6e4705345..8264eb9f5 100644
--- a/app/Filament/Resources/ServerResource/Pages/CreateServer.php
+++ b/app/Filament/Resources/ServerResource/Pages/CreateServer.php
@@ -81,7 +81,7 @@ class CreateServer extends CreateRecord
])
->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 @@ class CreateServer extends CreateRecord
->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 bd0f1fa21..b0bc6ef9b 100644
--- a/app/Filament/Resources/ServerResource/Pages/ListServers.php
+++ b/app/Filament/Resources/ServerResource/Pages/ListServers.php
@@ -4,6 +4,7 @@ namespace App\Filament\Resources\ServerResource\Pages;
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 @@ class ListServers extends ListRecords
->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 6e6386405..777f20d73 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 @@ class EditUser extends EditRecord
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 @@ class EditUser extends EditRecord
{
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 42dc17fab..3d9a1af18 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 @@ class ListUsers extends ListRecords
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 000000000..9a776c442
--- /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 a561da508..f7ed42496 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\StoreUserRequest;
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 @@ class UserController extends ApplicationApiController
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 319d59919..7ac7562f1 100644
--- a/app/Http/Controllers/Api/Client/ClientController.php
+++ b/app/Http/Controllers/Api/Client/ClientController.php
@@ -48,7 +48,7 @@ class ClientController extends ClientApiController
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 efef02681..53272eb70 100644
--- a/app/Http/Controllers/Api/Client/Servers/ActivityLogController.php
+++ b/app/Http/Controllers/Api/Client/Servers/ActivityLogController.php
@@ -13,6 +13,7 @@ use Illuminate\Database\Query\JoinClause;
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 @@ class ActivityLogController extends ClientApiController
// 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 af9cf97b1..9b59704f1 100644
--- a/app/Http/Controllers/Api/Remote/SftpAuthenticationController.php
+++ b/app/Http/Controllers/Api/Remote/SftpAuthenticationController.php
@@ -140,7 +140,7 @@ class SftpAuthenticationController extends Controller
*/
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 dc3296b06..6b86a86ee 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 a18c58baf..054739d27 100644
--- a/app/Http/Middleware/Api/Application/AuthenticateApplicationUser.php
+++ b/app/Http/Middleware/Api/Application/AuthenticateApplicationUser.php
@@ -15,7 +15,7 @@ class AuthenticateApplicationUser
{
/** @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 a8218b410..a8ef5d0f3 100644
--- a/app/Http/Middleware/Api/Client/Server/AuthenticateServerAccess.php
+++ b/app/Http/Middleware/Api/Client/Server/AuthenticateServerAccess.php
@@ -39,7 +39,7 @@ class AuthenticateServerAccess
// 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 @@ class AuthenticateServerAccess
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 ac1f4a1e2..470cc73b3 100644
--- a/app/Http/Middleware/RequireTwoFactorAuthentication.php
+++ b/app/Http/Middleware/RequireTwoFactorAuthentication.php
@@ -51,7 +51,7 @@ class RequireTwoFactorAuthentication
// 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 766ac92a4..54e9f5e92 100644
--- a/app/Http/Requests/Admin/AdminFormRequest.php
+++ b/app/Http/Requests/Admin/AdminFormRequest.php
@@ -21,7 +21,7 @@ abstract class AdminFormRequest extends FormRequest
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 db2f1ebe3..e3b91f597 100644
--- a/app/Http/Requests/Admin/NewUserFormRequest.php
+++ b/app/Http/Requests/Admin/NewUserFormRequest.php
@@ -22,7 +22,6 @@ class NewUserFormRequest extends AdminFormRequest
'name_last',
'password',
'language',
- 'root_admin',
])->toArray();
}
}
diff --git a/app/Http/Requests/Admin/UserFormRequest.php b/app/Http/Requests/Admin/UserFormRequest.php
index f9fc7e679..78a0a1bbd 100644
--- a/app/Http/Requests/Admin/UserFormRequest.php
+++ b/app/Http/Requests/Admin/UserFormRequest.php
@@ -22,7 +22,6 @@ class UserFormRequest extends AdminFormRequest
'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 000000000..43c005fe8
--- /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 000000000..48dc3d04e
--- /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 24e6e8940..43603639c 100644
--- a/app/Http/Requests/Api/Application/Users/StoreUserRequest.php
+++ b/app/Http/Requests/Api/Application/Users/StoreUserRequest.php
@@ -26,7 +26,6 @@ class StoreUserRequest extends ApplicationApiRequest
'password',
'language',
'timezone',
- 'root_admin',
])->toArray();
$response['first_name'] = $rules['name_first'];
@@ -56,7 +55,6 @@ class StoreUserRequest extends ApplicationApiRequest
'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 7a4d52430..bd68fb890 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 @@ abstract class SubuserRequest extends ClientApiRequest
$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 000000000..1274b2d6c
--- /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 fc96ed0f6..395f4994f 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -25,6 +25,9 @@ use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
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 @@ use App\Notifications\SendPasswordReset as ResetPasswordNotification;
* @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 @@ use App\Notifications\SendPasswordReset as ResetPasswordNotification;
* @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 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
*/
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 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
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 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
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 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
{
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 000000000..37aac9093
--- /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 856eeedf6..bd589f1b2 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 000000000..4f9d58b63
--- /dev/null
+++ b/app/Policies/MountPolicy.php
@@ -0,0 +1,10 @@
+subusers->where('user_id', $user->id)->first();
- if (!$subuser || empty($permission)) {
- return false;
+ // For "viewAny" the $server param is the class name
+ if (is_string($server)) {
+ return null;
}
- return in_array($permission, $subuser->permissions);
- }
-
- /**
- * Runs before any of the functions are called. Used to determine if user is root admin, if so, ignore permissions.
- */
- public function before(User $user, string $ability, Server $server): bool
- {
- if ($user->root_admin || $server->owner_id === $user->id) {
+ // 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 000000000..6f975b474
--- /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 19a43bef4..20a6b0dd5 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -6,6 +6,7 @@ use App\Extensions\Themes\Theme;
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 @@ class AppServiceProvider extends ServiceProvider
'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 3a0e4961c..dd58e317c 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 8c58c23f6..882f8b7cc 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 2f5369603..958d84442 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 @@ class UserCreationService
$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 939266f82..62ee4ceeb 100644
--- a/app/Transformers/Api/Application/BaseTransformer.php
+++ b/app/Transformers/Api/Application/BaseTransformer.php
@@ -77,7 +77,7 @@ abstract class BaseTransformer extends TransformerAbstract
// 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 000000000..968a54f8f
--- /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 000000000..d77b04e57
--- /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 ddec17e82..5cf800c08 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 @@ class UserTransformer extends BaseTransformer
'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 @@ class UserTransformer extends BaseTransformer
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 488ad1c95..58d5f13ae 100644
--- a/app/Transformers/Api/Client/ActivityLogTransformer.php
+++ b/app/Transformers/Api/Client/ActivityLogTransformer.php
@@ -113,6 +113,6 @@ class ActivityLogTransformer extends BaseClientTransformer
*/
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 6b42ae507..f875463e5 100644
--- a/app/Transformers/Api/Client/UserTransformer.php
+++ b/app/Transformers/Api/Client/UserTransformer.php
@@ -29,8 +29,8 @@ class UserTransformer extends BaseClientTransformer
'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 aaa3e9bc5..7e8ac8a0c 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 22543ba07..c8e3fa55a 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 000000000..4d4b6e16f
--- /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 510edaef1..3c6003cc2 100644
--- a/database/Factories/UserFactory.php
+++ b/database/Factories/UserFactory.php
@@ -33,19 +33,10 @@ class UserFactory extends Factory
'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 3a67335f8..2f7f6694e 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 000000000..9c7044b46
--- /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 000000000..128063ed1
--- /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 2fb03e00d..2bf52b77a 100644
--- a/lang/en/admin/user.php
+++ b/lang/en/admin/user.php
@@ -13,7 +13,6 @@ return [
'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 881ff4c01..8a211b463 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 f784ddfff..7372ccbee 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 0ec785137..76ba5b92f 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 c213c74ab..cf5cbb9df 100644
--- a/routes/api-application.php
+++ b/routes/api-application.php
@@ -19,6 +19,8 @@ Route::prefix('/users')->group(function () {
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::prefix('mounts')->group(function () {
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 e4fe09570..c104a3eae 100644
--- a/tests/Integration/Api/Application/ApplicationApiIntegrationTestCase.php
+++ b/tests/Integration/Api/Application/ApplicationApiIntegrationTestCase.php
@@ -6,6 +6,7 @@ use Illuminate\Http\Request;
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 @@ abstract class ApplicationApiIntegrationTestCase extends IntegrationTestCase
*/
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 26506b7a5..24f3996eb 100644
--- a/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php
+++ b/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php
@@ -38,7 +38,7 @@ class ExternalUserControllerTest extends ApplicationApiIntegrationTestCase
'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 b52c1c2c6..6787ff1f4 100644
--- a/tests/Integration/Api/Application/Users/UserControllerTest.php
+++ b/tests/Integration/Api/Application/Users/UserControllerTest.php
@@ -55,7 +55,7 @@ class UserControllerTest extends ApplicationApiIntegrationTestCase
'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 @@ class UserControllerTest extends ApplicationApiIntegrationTestCase
'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 29805711f..e4059f694 100644
--- a/tests/Integration/Api/Client/ClientControllerTest.php
+++ b/tests/Integration/Api/Client/ClientControllerTest.php
@@ -7,6 +7,7 @@ use App\Models\Server;
use App\Models\Subuser;
use App\Models\Allocation;
use App\Models\Permission;
+use App\Models\Role;
class ClientControllerTest extends ClientApiIntegrationTestCase
{
@@ -47,7 +48,7 @@ class ClientControllerTest extends ClientApiIntegrationTestCase
{
/** @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 @@ class ClientControllerTest extends ClientApiIntegrationTestCase
{
/** @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 @@ class ClientControllerTest extends ClientApiIntegrationTestCase
{
/** @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 2affbef73..031eedce7 100644
--- a/tests/Integration/Api/Remote/SftpAuthenticationControllerTest.php
+++ b/tests/Integration/Api/Remote/SftpAuthenticationControllerTest.php
@@ -7,6 +7,7 @@ use App\Models\Node;
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 @@ class SftpAuthenticationControllerTest extends IntegrationTestCase
->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 @@ class SftpAuthenticationControllerTest extends IntegrationTestCase
->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 fabaa7762..abcd9d84d 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -5,6 +5,7 @@ namespace App\Tests;
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 @@ abstract class TestCase extends BaseTestCase
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 a0564ea49..22f60e74f 100644
--- a/tests/Traits/Http/RequestMockHelpers.php
+++ b/tests/Traits/Http/RequestMockHelpers.php
@@ -35,13 +35,14 @@ trait RequestMockHelpers
/**
* 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 87f647819..40b15775e 100644
--- a/tests/Unit/Http/Middleware/AdminAuthenticateTest.php
+++ b/tests/Unit/Http/Middleware/AdminAuthenticateTest.php
@@ -4,6 +4,7 @@ namespace App\Tests\Unit\Http\Middleware;
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 @@ class AdminAuthenticateTest extends MiddlewareTestCase
{
$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 bfaf4cd75..6b0bdd277 100644
--- a/tests/Unit/Http/Middleware/Api/Application/AuthenticateUserTest.php
+++ b/tests/Unit/Http/Middleware/Api/Application/AuthenticateUserTest.php
@@ -27,7 +27,7 @@ class AuthenticateUserTest extends MiddlewareTestCase
{
$this->expectException(AccessDeniedHttpException::class);
- $this->generateRequestUserModel(['root_admin' => false]);
+ $this->generateRequestUserModel(false);
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions());
}
@@ -37,7 +37,7 @@ class AuthenticateUserTest extends MiddlewareTestCase
*/
public function testAdminUser(): void
{
- $this->generateRequestUserModel(['root_admin' => true]);
+ $this->generateRequestUserModel(true);
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions());
}