Admin Roles (#502)

* add spatie/permissions

* add policies

* add role resource

* add root admin role handling

* replace some "root_admin" with function

* add model specific permissions

* make permission selection nicer

* fix user creation

* fix tests

* add back subuser checks in server policy

* add custom model for role

* assign new users to role if root_admin is set

* add api for roles

* fix phpstan

* add permissions for settings page

* remove "restore" and "forceDelete" permissions

* add user count to list

* prevent deletion if role has users

* update user list

* fix server policy

* remove old `root_admin` column

* small refactor

* fix tests

* forgot can checks here

* forgot use

* disable editing own roles & disable assigning root admin

* don't allow to rename root admin role

* remove php bombing exception handler

* fix role assignment when creating a user

* fix disableOptionWhen

* fix missing `root_admin` attribute on react frontend

* add permission check for bulk delete

* rename viewAny to viewList

* improve canAccessPanel check

* fix admin not displaying for non-root admins

* make sure non root admins can't edit root admins

* fix import

* fix settings page permission check

* fix server permissions for non-subusers

* fix settings page permission check v2

* small cleanup

* cleanup config file

* move consts from resouce into enum & model

* Update database/migrations/2024_08_01_114538_remove_root_admin_column.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* fix config

* fix phpstan

* fix phpstan 2.0

---------

Co-authored-by: Lance Pioch <lancepioch@gmail.com>
This commit is contained in:
Boy132 2024-09-21 12:27:41 +02:00 committed by GitHub
parent 68a0cbbf10
commit fc643f57f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
81 changed files with 1336 additions and 220 deletions

View File

@ -52,7 +52,7 @@ class MakeUserCommand extends Command
['UUID', $user->uuid], ['UUID', $user->uuid],
['Email', $user->email], ['Email', $user->email],
['Username', $user->username], ['Username', $user->username],
['Admin', $user->root_admin ? 'Yes' : 'No'], ['Admin', $user->isRootAdmin() ? 'Yes' : 'No'],
]); ]);
return 0; return 0;

View File

@ -0,0 +1,16 @@
<?php
namespace App\Enums;
enum RolePermissionModels: string
{
case ApiKey = 'apikey';
case DatabaseHost = 'databasehost';
case Database = 'database';
case Egg = 'egg';
case Mount = 'mount';
case Node = 'node';
case Role = 'role';
case Server = 'server';
case User = 'user';
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Enums;
enum RolePermissionPrefixes: string
{
case ViewAny = 'viewList';
case View = 'view';
case Create = 'create';
case Update = 'update';
case Delete = 'delete';
}

View File

@ -49,12 +49,18 @@ class Settings extends Page implements HasForms
$this->form->fill(); $this->form->fill();
} }
public static function canAccess(): bool
{
return auth()->user()->can('view settings');
}
protected function getFormSchema(): array protected function getFormSchema(): array
{ {
return [ return [
Tabs::make('Tabs') Tabs::make('Tabs')
->columns() ->columns()
->persistTabInQueryString() ->persistTabInQueryString()
->disabled(fn () => !auth()->user()->can('update settings'))
->tabs([ ->tabs([
Tab::make('general') Tab::make('general')
->label('General') ->label('General')
@ -147,10 +153,12 @@ class Settings extends Page implements HasForms
->color('danger') ->color('danger')
->icon('tabler-trash') ->icon('tabler-trash')
->requiresConfirmation() ->requiresConfirmation()
->authorize(fn () => auth()->user()->can('update settings'))
->action(fn (Set $set) => $set('TRUSTED_PROXIES', [])), ->action(fn (Set $set) => $set('TRUSTED_PROXIES', [])),
FormAction::make('cloudflare') FormAction::make('cloudflare')
->label('Set to Cloudflare IPs') ->label('Set to Cloudflare IPs')
->icon('tabler-brand-cloudflare') ->icon('tabler-brand-cloudflare')
->authorize(fn () => auth()->user()->can('update settings'))
->action(fn (Set $set) => $set('TRUSTED_PROXIES', [ ->action(fn (Set $set) => $set('TRUSTED_PROXIES', [
'173.245.48.0/20', '173.245.48.0/20',
'103.21.244.0/22', '103.21.244.0/22',
@ -226,6 +234,7 @@ class Settings extends Page implements HasForms
->label('Send Test Mail') ->label('Send Test Mail')
->icon('tabler-send') ->icon('tabler-send')
->hidden(fn (Get $get) => $get('MAIL_MAILER') === 'log') ->hidden(fn (Get $get) => $get('MAIL_MAILER') === 'log')
->authorize(fn () => auth()->user()->can('update settings'))
->action(function () { ->action(function () {
try { try {
MailNotification::route('mail', auth()->user()->email) MailNotification::route('mail', auth()->user()->email)
@ -561,12 +570,9 @@ class Settings extends Page implements HasForms
return [ return [
Action::make('save') Action::make('save')
->action('save') ->action('save')
->authorize(fn () => auth()->user()->can('update settings'))
->keyBindings(['mod+s']), ->keyBindings(['mod+s']),
]; ];
} }
protected function getFormActions(): array
{
return [];
}
} }

View File

@ -42,7 +42,8 @@ class ListDatabaseHosts extends ListRecords
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
DeleteBulkAction::make(), DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete databasehost')),
]), ]),
]); ]);
} }

View File

@ -4,10 +4,10 @@ namespace App\Filament\Resources\DatabaseResource\Pages;
use App\Filament\Resources\DatabaseResource; use App\Filament\Resources\DatabaseResource;
use Filament\Actions; use Filament\Actions;
use Filament\Tables\Actions\EditAction;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\BulkActionGroup; use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction; use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
@ -48,7 +48,8 @@ class ListDatabases extends ListRecords
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
DeleteBulkAction::make(), DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete database')),
]), ]),
]); ]);
} }

View File

@ -2,12 +2,15 @@
namespace App\Filament\Resources\EggResource\Pages; namespace App\Filament\Resources\EggResource\Pages;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
use App\Filament\Resources\EggResource; use App\Filament\Resources\EggResource;
use App\Filament\Resources\EggResource\RelationManagers\ServersRelationManager; use App\Filament\Resources\EggResource\RelationManagers\ServersRelationManager;
use App\Models\Egg; use App\Models\Egg;
use App\Services\Eggs\Sharing\EggExporterService;
use App\Services\Eggs\Sharing\EggImporterService; use App\Services\Eggs\Sharing\EggImporterService;
use Exception; use Exception;
use Filament\Actions; use Filament\Actions;
use Filament\Forms;
use Filament\Forms\Components\Checkbox; use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Fieldset; use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\FileUpload;
@ -22,12 +25,9 @@ use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord; 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 class EditEgg extends EditRecord
{ {
@ -245,14 +245,13 @@ class EditEgg extends EditRecord
Actions\DeleteAction::make('deleteEgg') Actions\DeleteAction::make('deleteEgg')
->disabled(fn (Egg $egg): bool => $egg->servers()->count() > 0) ->disabled(fn (Egg $egg): bool => $egg->servers()->count() > 0)
->label(fn (Egg $egg): string => $egg->servers()->count() <= 0 ? 'Delete' : 'In Use'), ->label(fn (Egg $egg): string => $egg->servers()->count() <= 0 ? 'Delete' : 'In Use'),
Actions\Action::make('exportEgg') Actions\Action::make('exportEgg')
->label('Export') ->label('Export')
->color('primary') ->color('primary')
->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) { ->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) {
echo $service->handle($egg->id); echo $service->handle($egg->id);
}, 'egg-' . $egg->getKebabName() . '.json')), }, 'egg-' . $egg->getKebabName() . '.json'))
->authorize(fn () => auth()->user()->can('export egg')),
Actions\Action::make('importEgg') Actions\Action::make('importEgg')
->label('Import') ->label('Import')
->form([ ->form([
@ -321,8 +320,8 @@ class EditEgg extends EditRecord
->title('Import Success') ->title('Import Success')
->success() ->success()
->send(); ->send();
}), })
->authorize(fn () => auth()->user()->can('import egg')),
$this->getSaveFormAction()->formId('form'), $this->getSaveFormAction()->formId('form'),
]; ];
} }

View File

@ -14,13 +14,13 @@ use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Tables;
use Filament\Tables\Actions\BulkActionGroup; use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction; use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction; use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Filament\Tables;
class ListEggs extends ListRecords class ListEggs extends ListRecords
{ {
@ -55,11 +55,13 @@ class ListEggs extends ListRecords
->color('primary') ->color('primary')
->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) { ->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) {
echo $service->handle($egg->id); echo $service->handle($egg->id);
}, 'egg-' . $egg->getKebabName() . '.json')), }, 'egg-' . $egg->getKebabName() . '.json'))
->authorize(fn () => auth()->user()->can('export egg')),
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
DeleteBulkAction::make(), DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete egg')),
]), ]),
]); ]);
} }
@ -138,7 +140,8 @@ class ListEggs extends ListRecords
->title('Import Success') ->title('Import Success')
->success() ->success()
->send(); ->send();
}), })
->authorize(fn () => auth()->user()->can('import egg')),
]; ];
} }
} }

View File

@ -43,7 +43,8 @@ class ListMounts extends ListRecords
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
DeleteBulkAction::make(), DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete mount')),
]), ]),
]) ])
->emptyStateIcon('tabler-layers-linked') ->emptyStateIcon('tabler-layers-linked')

View File

@ -84,7 +84,8 @@ class ListNodes extends ListRecords
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
DeleteBulkAction::make(), DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete node')),
]), ]),
]) ])
->emptyStateIcon('tabler-server-2') ->emptyStateIcon('tabler-server-2')

View File

@ -7,12 +7,12 @@ use App\Models\Node;
use App\Services\Allocations\AssignmentService; use App\Services\Allocations\AssignmentService;
use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput; 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\Form;
use Filament\Forms\Set;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\TextInputColumn; use Filament\Tables\Columns\TextInputColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
@ -152,7 +152,8 @@ class AllocationsRelationManager extends RelationManager
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
DeleteBulkAction::make(), DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete allocation')),
]), ]),
]); ]);
} }

View File

@ -0,0 +1,146 @@
<?php
namespace App\Filament\Resources;
use App\Enums\RolePermissionModels;
use App\Enums\RolePermissionPrefixes;
use App\Filament\Resources\RoleResource\Pages;
use App\Models\Role;
use Filament\Facades\Filament;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Resources\Resource;
use Illuminate\Support\Str;
class RoleResource extends Resource
{
protected static ?string $model = Role::class;
protected static ?string $navigationIcon = 'tabler-users-group';
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;
}
public static function form(Form $form): Form
{
$permissions = [];
foreach (RolePermissionModels::cases() as $model) {
$options = [];
foreach (RolePermissionPrefixes::cases() as $prefix) {
$options[$prefix->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'),
];
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use App\Models\Role;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Spatie\Permission\Models\Permission;
/**
* @property Role $record
*/
class CreateRole extends CreateRecord
{
protected static string $resource = RoleResource::class;
protected static bool $canCreateAnother = false;
public Collection $permissions;
protected function mutateFormDataBeforeCreate(array $data): array
{
$this->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);
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use App\Models\Role;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Spatie\Permission\Models\Permission;
/**
* @property Role $record
*/
class EditRole extends EditRecord
{
protected static string $resource = RoleResource::class;
public Collection $permissions;
protected function mutateFormDataBeforeSave(array $data): array
{
$this->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')),
];
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use App\Models\Role;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\CreateAction as CreateActionTable;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class ListRoles extends ListRecords
{
protected static string $resource = RoleResource::class;
public function table(Table $table): Table
{
return $table
->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'),
];
}
}

View File

@ -81,7 +81,7 @@ class CreateServer extends CreateRecord
]) ])
->relationship('user', 'username') ->relationship('user', 'username')
->searchable(['username', 'email']) ->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([ ->createOptionForm([
Forms\Components\TextInput::make('username') Forms\Components\TextInput::make('username')
->alphaNum() ->alphaNum()
@ -98,21 +98,6 @@ class CreateServer extends CreateRecord
->hintIcon('tabler-question-mark') ->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.') ->hintIconTooltip('Providing a user password is optional. New user email will prompt users to create a password the first time they login.')
->password(), ->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) { ->createOptionUsing(function ($data) {
resolve(UserCreationService::class)->handle($data); resolve(UserCreationService::class)->handle($data);

View File

@ -4,6 +4,7 @@ namespace App\Filament\Resources\ServerResource\Pages;
use App\Filament\Resources\ServerResource; use App\Filament\Resources\ServerResource;
use App\Models\Server; use App\Models\Server;
use App\Models\User;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\CreateAction; use Filament\Tables\Actions\CreateAction;
@ -76,7 +77,13 @@ class ListServers extends ListRecords
->actions([ ->actions([
Tables\Actions\Action::make('View') Tables\Actions\Action::make('View')
->icon('tabler-terminal') ->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(), Tables\Actions\EditAction::make(),
]) ])
->emptyStateIcon('tabler-brand-docker') ->emptyStateIcon('tabler-brand-docker')

View File

@ -3,13 +3,16 @@
namespace App\Filament\Resources\UserResource\Pages; namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource; use App\Filament\Resources\UserResource;
use App\Services\Exceptions\FilamentExceptionHandler; use App\Models\Role;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
use App\Models\User; 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\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
class EditUser extends EditRecord class EditUser extends EditRecord
@ -20,54 +23,33 @@ class EditUser extends EditRecord
return $form return $form
->schema([ ->schema([
Section::make()->schema([ Section::make()->schema([
Forms\Components\TextInput::make('username')->required()->maxLength(255), TextInput::make('username')->required()->maxLength(255),
Forms\Components\TextInput::make('email')->email()->required()->maxLength(255), TextInput::make('email')->email()->required()->maxLength(255),
TextInput::make('password')
Forms\Components\TextInput::make('password')
->dehydrateStateUsing(fn (string $state): string => Hash::make($state)) ->dehydrateStateUsing(fn (string $state): string => Hash::make($state))
->dehydrated(fn (?string $state): bool => filled($state)) ->dehydrated(fn (?string $state): bool => filled($state))
->required(fn (string $operation): bool => $operation === 'create') ->required(fn (string $operation): bool => $operation === 'create')
->password(), ->password(),
Select::make('language')
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')
->required() ->required()
->hidden() ->hidden()
->default('en') ->default('en')
->options(fn (User $user) => $user->getAvailableLanguages()), ->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(), ])->columns(),
]); ]);
} }
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ 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')) ->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), ->disabled(fn (User $user) => auth()->user()->id === $user->id || $user->servers()->count() > 0),
$this->getSaveFormAction()->formId('form'), $this->getSaveFormAction()->formId('form'),
@ -78,9 +60,4 @@ class EditUser extends EditRecord
{ {
return []; return [];
} }
public function exception($exception, $stopPropagation): void
{
(new FilamentExceptionHandler())->handle($exception, $stopPropagation);
}
} }

View File

@ -3,14 +3,22 @@
namespace App\Filament\Resources\UserResource\Pages; namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource; use App\Filament\Resources\UserResource;
use App\Models\Role;
use App\Models\User; use App\Models\User;
use App\Services\Users\UserCreationService; 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\Notifications\Notification;
use Filament\Resources\Pages\ListRecords; 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\Table;
use Filament\Tables;
use Filament\Forms;
class ListUsers extends ListRecords class ListUsers extends ListRecords
{ {
@ -21,101 +29,102 @@ class ListUsers extends ListRecords
return $table return $table
->searchable(false) ->searchable(false)
->columns([ ->columns([
Tables\Columns\ImageColumn::make('picture') ImageColumn::make('picture')
->visibleFrom('lg') ->visibleFrom('lg')
->label('') ->label('')
->extraImgAttributes(['class' => 'rounded-full']) ->extraImgAttributes(['class' => 'rounded-full'])
->defaultImageUrl(fn (User $user) => 'https://gravatar.com/avatar/' . md5(strtolower($user->email))), ->defaultImageUrl(fn (User $user) => 'https://gravatar.com/avatar/' . md5(strtolower($user->email))),
Tables\Columns\TextColumn::make('external_id') TextColumn::make('external_id')
->searchable() ->searchable()
->hidden(), ->hidden(),
Tables\Columns\TextColumn::make('uuid') TextColumn::make('uuid')
->label('UUID') ->label('UUID')
->hidden() ->hidden()
->searchable(), ->searchable(),
Tables\Columns\TextColumn::make('username') TextColumn::make('username')
->searchable(), ->searchable(),
Tables\Columns\TextColumn::make('email') TextColumn::make('email')
->searchable() ->searchable()
->icon('tabler-mail'), ->icon('tabler-mail'),
Tables\Columns\IconColumn::make('root_admin') IconColumn::make('use_totp')
->visibleFrom('md') ->label('2FA')
->label('Admin')
->boolean()
->trueIcon('tabler-star-filled')
->falseIcon('tabler-star-off')
->sortable(),
Tables\Columns\IconColumn::make('use_totp')->label('2FA')
->visibleFrom('lg') ->visibleFrom('lg')
->icon(fn (User $user) => $user->use_totp ? 'tabler-lock' : 'tabler-lock-open-off') ->icon(fn (User $user) => $user->use_totp ? 'tabler-lock' : 'tabler-lock-open-off')
->boolean()->sortable(), ->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') ->counts('servers')
->icon('tabler-server') ->icon('tabler-server')
->label('Servers'), ->label('Servers'),
Tables\Columns\TextColumn::make('subusers_count') TextColumn::make('subusers_count')
->visibleFrom('sm') ->visibleFrom('sm')
->label('Subusers') ->label('Subusers')
->counts('subusers') ->counts('subusers')
->icon('tabler-users'), ->icon('tabler-users'),
// ->formatStateUsing(fn (string $state, $record): string => (string) ($record->servers_count + $record->subusers_count)) // ->formatStateUsing(fn (string $state, $record): string => (string) ($record->servers_count + $record->subusers_count))
]) ])
->filters([
//
])
->actions([ ->actions([
Tables\Actions\EditAction::make(), EditAction::make(),
]) ])
->checkIfRecordIsSelectableUsing(fn (User $user) => auth()->user()->id !== $user->id && !$user->servers_count) ->checkIfRecordIsSelectableUsing(fn (User $user) => auth()->user()->id !== $user->id && !$user->servers_count)
->bulkActions([ ->bulkActions([
Tables\Actions\BulkActionGroup::make([ BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(), DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete user')),
]), ]),
]); ]);
} }
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make('create') CreateAction::make('create')
->label('Create User') ->label('Create User')
->createAnother(false) ->createAnother(false)
->form([ ->form([
Forms\Components\Grid::make() Grid::make()
->schema([ ->schema([
Forms\Components\TextInput::make('username') TextInput::make('username')
->alphaNum() ->alphaNum()
->required() ->required()
->maxLength(255), ->maxLength(255),
Forms\Components\TextInput::make('email') TextInput::make('email')
->email() ->email()
->required() ->required()
->unique() ->unique()
->maxLength(255), ->maxLength(255),
TextInput::make('password')
Forms\Components\TextInput::make('password')
->hintIcon('tabler-question-mark') ->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.') ->hintIconTooltip('Providing a user password is optional. New user email will prompt users to create a password the first time they login.')
->password(), ->password(),
CheckboxList::make('roles')
Forms\Components\ToggleButtons::make('root_admin') ->disableOptionWhen(fn (string $value): bool => $value == Role::getRootAdmin()->id)
->label('Administrator (Root)') ->relationship('roles', 'name')
->options([ ->dehydrated()
false => 'No', ->label('Admin Roles')
true => 'Admin', ->columnSpanFull()
]) ->bulkToggleable(false),
->colors([
false => 'primary',
true => 'danger',
])
->inline()
->required()
->default(false),
]), ]),
]) ])
->successRedirectUrl(route('filament.admin.resources.users.index')) ->successRedirectUrl(route('filament.admin.resources.users.index'))
->action(function (array $data) { ->action(function (array $data) {
resolve(UserCreationService::class)->handle($data); $roles = $data['roles'];
Notification::make()->title('User Created!')->success()->send(); $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'); return redirect()->route('filament.admin.resources.users.index');
}), }),

View File

@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Api\Application\Roles;
use Illuminate\Http\Response;
use Illuminate\Http\JsonResponse;
use App\Models\Role;
use Spatie\QueryBuilder\QueryBuilder;
use App\Transformers\Api\Application\RoleTransformer;
use App\Http\Controllers\Api\Application\ApplicationApiController;
use App\Http\Requests\Api\Application\Roles\GetRoleRequest;
use App\Http\Requests\Api\Application\Roles\StoreRoleRequest;
use App\Http\Requests\Api\Application\Roles\DeleteRoleRequest;
use App\Http\Requests\Api\Application\Roles\UpdateRoleRequest;
class RoleController extends ApplicationApiController
{
/**
* Return all the roles currently registered on the Panel.
*/
public function index(GetRoleRequest $request): array
{
$roles = QueryBuilder::for(Role::query())
->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();
}
}

View File

@ -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\DeleteUserRequest;
use App\Http\Requests\Api\Application\Users\UpdateUserRequest; use App\Http\Requests\Api\Application\Users\UpdateUserRequest;
use App\Http\Controllers\Api\Application\ApplicationApiController; use App\Http\Controllers\Api\Application\ApplicationApiController;
use App\Http\Requests\Api\Application\Users\AssignUserRolesRequest;
class UserController extends ApplicationApiController class UserController extends ApplicationApiController
{ {
@ -75,6 +76,19 @@ class UserController extends ApplicationApiController
return $response->toArray(); 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 * Store a new user on the system. Returns the created user and an HTTP/201
* header on successful creation. * header on successful creation.

View File

@ -48,7 +48,7 @@ class ClientController extends ClientApiController
if (in_array($type, ['admin', 'admin-all'])) { 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 // 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. // make it a query that will never return any results back.
if (!$user->root_admin) { if (!$user->isRootAdmin()) {
$builder->whereRaw('1 = 2'); $builder->whereRaw('1 = 2');
} else { } else {
$builder = $type === 'admin-all' $builder = $type === 'admin-all'

View File

@ -13,6 +13,7 @@ use Illuminate\Database\Query\JoinClause;
use App\Http\Requests\Api\Client\ClientApiRequest; use App\Http\Requests\Api\Client\ClientApiRequest;
use App\Transformers\Api\Client\ActivityLogTransformer; use App\Transformers\Api\Client\ActivityLogTransformer;
use App\Http\Controllers\Api\Client\ClientApiController; use App\Http\Controllers\Api\Client\ClientApiController;
use App\Models\Role;
class ActivityLogController extends ClientApiController 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 // 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. // painful so for now we'll execute a simpler query.
$subusers = $server->subusers()->pluck('user_id')->merge([$server->owner_id]); $subusers = $server->subusers()->pluck('user_id')->merge([$server->owner_id]);
$rootAdmins = Role::getRootAdmin()->users()->pluck('id');
$builder->select('activity_logs.*') $builder->select('activity_logs.*')
->leftJoin('users', function (JoinClause $join) { ->leftJoin('users', function (JoinClause $join) {
$join->on('users.id', 'activity_logs.actor_id') $join->on('users.id', 'activity_logs.actor_id')
->where('activity_logs.actor_type', (new User())->getMorphClass()); ->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') $builder->whereNull('users.id')
->orWhere('users.root_admin', 0) ->orWhereNotIn('users.id', $rootAdmins)
->orWhereIn('users.id', $subusers); ->orWhereIn('users.id', $subusers);
}); });
}) })

View File

@ -140,7 +140,7 @@ class SftpAuthenticationController extends Controller
*/ */
protected function validateSftpAccess(User $user, Server $server): 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); $permissions = $this->permissions->handle($server, $user);
if (!in_array(Permission::ACTION_FILE_SFTP, $permissions)) { if (!in_array(Permission::ACTION_FILE_SFTP, $permissions)) {

View File

@ -14,7 +14,7 @@ class AdminAuthenticate
*/ */
public function handle(Request $request, \Closure $next): mixed public function handle(Request $request, \Closure $next): mixed
{ {
if (!$request->user() || !$request->user()->root_admin) { if (!$request->user() || !$request->user()->isRootAdmin()) {
throw new AccessDeniedHttpException(); throw new AccessDeniedHttpException();
} }

View File

@ -15,7 +15,7 @@ class AuthenticateApplicationUser
{ {
/** @var \App\Models\User|null $user */ /** @var \App\Models\User|null $user */
$user = $request->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.'); throw new AccessDeniedHttpException('This account does not have permission to access the API.');
} }

View File

@ -39,7 +39,7 @@ class AuthenticateServerAccess
// At the very least, ensure that the user trying to make this request is the // 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 // server owner, a subuser, or a root admin. We'll leave it up to the controllers
// to authenticate more detailed permissions if needed. // 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. // Check for subuser status.
if (!$server->subusers->contains('user_id', $user->id)) { if (!$server->subusers->contains('user_id', $user->id)) {
throw new NotFoundHttpException(trans('exceptions.api.resource_not_found')); 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')) { if (($server->isSuspended() || $server->node->isUnderMaintenance()) && !$request->routeIs('api:client:server.resources')) {
throw $exception; throw $exception;
} }
if (!$user->root_admin || !$request->routeIs($this->except)) { if (!$user->isRootAdmin() || !$request->routeIs($this->except)) {
throw $exception; throw $exception;
} }
} }

View File

@ -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 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) { if ($level === self::LEVEL_NONE || $user->use_totp) {
return $next($request); return $next($request);
} elseif ($level === self::LEVEL_ADMIN && !$user->root_admin) { } elseif ($level === self::LEVEL_ADMIN && !$user->isRootAdmin()) {
return $next($request); return $next($request);
} }

View File

@ -21,7 +21,7 @@ abstract class AdminFormRequest extends FormRequest
return false; return false;
} }
return (bool) $this->user()->root_admin; return $this->user()->isRootAdmin();
} }
/** /**

View File

@ -22,7 +22,6 @@ class NewUserFormRequest extends AdminFormRequest
'name_last', 'name_last',
'password', 'password',
'language', 'language',
'root_admin',
])->toArray(); ])->toArray();
} }
} }

View File

@ -22,7 +22,6 @@ class UserFormRequest extends AdminFormRequest
'name_last', 'name_last',
'password', 'password',
'language', 'language',
'root_admin',
])->toArray(); ])->toArray();
} }
} }

View File

@ -0,0 +1,13 @@
<?php
namespace App\Http\Requests\Api\Application\Roles;
use App\Services\Acl\Api\AdminAcl;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
class DeleteRoleRequest extends ApplicationApiRequest
{
protected ?string $resource = AdminAcl::RESOURCE_ROLES;
protected int $permission = AdminAcl::WRITE;
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Http\Requests\Api\Application\Roles;
use App\Services\Acl\Api\AdminAcl;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
class GetRoleRequest extends ApplicationApiRequest
{
protected ?string $resource = AdminAcl::RESOURCE_ROLES;
protected int $permission = AdminAcl::READ;
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Api\Application\Roles;
use App\Services\Acl\Api\AdminAcl;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
class StoreRoleRequest extends ApplicationApiRequest
{
protected ?string $resource = AdminAcl::RESOURCE_ROLES;
protected int $permission = AdminAcl::WRITE;
public function rules(array $rules = null): array
{
return [
'name' => 'required|string',
'guard_name' => 'nullable|string',
];
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace App\Http\Requests\Api\Application\Roles;
class UpdateRoleRequest extends StoreRoleRequest
{
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Requests\Api\Application\Users;
class AssignUserRolesRequest extends StoreUserRequest
{
/**
* Return the validation rules for this request.
*/
public function rules(array $rules = null): array
{
return [
'roles' => 'array',
'roles.*' => 'string',
];
}
}

View File

@ -26,7 +26,6 @@ class StoreUserRequest extends ApplicationApiRequest
'password', 'password',
'language', 'language',
'timezone', 'timezone',
'root_admin',
])->toArray(); ])->toArray();
$response['first_name'] = $rules['name_first']; $response['first_name'] = $rules['name_first'];
@ -56,7 +55,6 @@ class StoreUserRequest extends ApplicationApiRequest
'external_id' => 'Third Party Identifier', 'external_id' => 'Third Party Identifier',
'name_first' => 'First Name', 'name_first' => 'First Name',
'name_last' => 'Last Name', 'name_last' => 'Last Name',
'root_admin' => 'Root Administrator Status',
]; ];
} }
} }

View File

@ -56,7 +56,7 @@ abstract class SubuserRequest extends ClientApiRequest
$server = $this->route()->parameter('server'); $server = $this->route()->parameter('server');
// If we are a root admin or the server owner, no need to perform these checks. // 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; return;
} }

48
app/Models/Role.php Normal file
View File

@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Spatie\Permission\Models\Role as BaseRole;
/**
* @property int $id
* @property string $name
* @property string $guard_name
* @property \Illuminate\Database\Eloquent\Collection|\Spatie\Permission\Models\Permission[] $permissions
* @property int|null $permissions_count
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\User[] $users
* @property int|null $users_count
*/
class Role extends BaseRole
{
public const RESOURCE_NAME = 'role';
public const ROOT_ADMIN = 'Root Admin';
public const MODEL_SPECIFIC_PERMISSIONS = [
'egg' => [
'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;
}
}

View File

@ -25,6 +25,9 @@ use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract; use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use App\Notifications\SendPasswordReset as ResetPasswordNotification; 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. * App\Models\User.
@ -40,7 +43,6 @@ use App\Notifications\SendPasswordReset as ResetPasswordNotification;
* @property string|null $remember_token * @property string|null $remember_token
* @property string $language * @property string $language
* @property string $timezone * @property string $timezone
* @property bool $root_admin
* @property bool $use_totp * @property bool $use_totp
* @property string|null $totp_secret * @property string|null $totp_secret
* @property \Illuminate\Support\Carbon|null $totp_authenticated_at * @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 whereNameLast($value)
* @method static Builder|User wherePassword($value) * @method static Builder|User wherePassword($value)
* @method static Builder|User whereRememberToken($value) * @method static Builder|User whereRememberToken($value)
* @method static Builder|User whereRootAdmin($value)
* @method static Builder|User whereTotpAuthenticatedAt($value) * @method static Builder|User whereTotpAuthenticatedAt($value)
* @method static Builder|User whereTotpSecret($value) * @method static Builder|User whereTotpSecret($value)
* @method static Builder|User whereUpdatedAt($value) * @method static Builder|User whereUpdatedAt($value)
@ -94,6 +95,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
use AvailableLanguages; use AvailableLanguages;
use CanResetPassword; use CanResetPassword;
use HasAccessTokens; use HasAccessTokens;
use HasRoles;
use Notifiable; use Notifiable;
public const USER_LEVEL_USER = 0; public const USER_LEVEL_USER = 0;
@ -131,7 +133,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'totp_secret', 'totp_secret',
'totp_authenticated_at', 'totp_authenticated_at',
'gravatar', 'gravatar',
'root_admin',
'oauth', 'oauth',
]; ];
@ -145,7 +146,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
*/ */
protected $attributes = [ protected $attributes = [
'external_id' => null, 'external_id' => null,
'root_admin' => false,
'language' => 'en', 'language' => 'en',
'timezone' => 'UTC', 'timezone' => 'UTC',
'use_totp' => false, 'use_totp' => false,
@ -166,7 +166,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'name_first' => 'nullable|string|between:0,255', 'name_first' => 'nullable|string|between:0,255',
'name_last' => 'nullable|string|between:0,255', 'name_last' => 'nullable|string|between:0,255',
'password' => 'sometimes|nullable|string', 'password' => 'sometimes|nullable|string',
'root_admin' => 'boolean',
'language' => 'string', 'language' => 'string',
'timezone' => 'string', 'timezone' => 'string',
'use_totp' => 'boolean', 'use_totp' => 'boolean',
@ -177,7 +176,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
protected function casts(): array protected function casts(): array
{ {
return [ return [
'root_admin' => 'boolean',
'use_totp' => 'boolean', 'use_totp' => 'boolean',
'gravatar' => 'boolean', 'gravatar' => 'boolean',
'totp_authenticated_at' => 'datetime', 'totp_authenticated_at' => 'datetime',
@ -226,7 +224,10 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
*/ */
public function toReactObject(): array public function toReactObject(): array
{ {
return collect($this->toArray())->except(['id', 'external_id'])->toArray(); return array_merge(collect($this->toArray())->except(['id', 'external_id'])->toArray(), [
'root_admin' => $this->isRootAdmin(),
'admin' => $this->canAccessPanel(Filament::getPanel('admin')),
]);
} }
/** /**
@ -315,7 +316,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
protected function checkPermission(Server $server, string $permission = ''): bool 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; return true;
} }
@ -351,14 +352,23 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
public function isLastRootAdmin(): 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)); 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 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 public function getFilamentName(): string
@ -370,4 +380,13 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
{ {
return 'https://gravatar.com/avatar/' . md5(strtolower($this->email)); 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();
}
} }

View File

@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class ApiKeyPolicy
{
use DefaultPolicies;
protected string $modelName = 'apikey';
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class DatabaseHostPolicy
{
use DefaultPolicies;
protected string $modelName = 'databasehost';
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class DatabasePolicy
{
use DefaultPolicies;
protected string $modelName = 'database';
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Policies;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
trait DefaultPolicies
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return $user->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);
}
}

View File

@ -2,12 +2,9 @@
namespace App\Policies; namespace App\Policies;
use App\Models\User;
class EggPolicy class EggPolicy
{ {
public function create(User $user): bool use DefaultPolicies;
{
return true; protected string $modelName = 'egg';
}
} }

View File

@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class MountPolicy
{
use DefaultPolicies;
protected string $modelName = 'mount';
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class NodePolicy
{
use DefaultPolicies;
protected string $modelName = 'node';
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class RolePolicy
{
use DefaultPolicies;
protected string $modelName = 'role';
}

View File

@ -2,34 +2,38 @@
namespace App\Policies; namespace App\Policies;
use App\Models\User;
use App\Models\Server; use App\Models\Server;
use App\Models\User;
class ServerPolicy class ServerPolicy
{ {
use DefaultPolicies;
protected string $modelName = 'server';
/** /**
* Checks if the user has the given permission on/for the server. * Runs before any of the functions are called. Used to determine if the (sub-)user has permissions.
*/ */
protected function checkPermission(User $user, Server $server, string $permission): bool public function before(User $user, string $ability, string|Server $server): ?bool
{ {
$subuser = $server->subusers->where('user_id', $user->id)->first(); // For "viewAny" the $server param is the class name
if (!$subuser || empty($permission)) { if (is_string($server)) {
return false; return null;
} }
return in_array($permission, $subuser->permissions); // Owner has full server permissions
} if ($server->owner_id === $user->id) {
/**
* 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) {
return true; 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;
} }
/** /**

View File

@ -0,0 +1,26 @@
<?php
namespace App\Policies;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
class UserPolicy
{
use DefaultPolicies {
update as defaultUpdate;
delete as defaultDelete;
}
protected string $modelName = 'user';
public function update(User $user, Model $model): bool
{
return $user->canTarget($model) && $this->defaultUpdate($user, $model);
}
public function delete(User $user, Model $model): bool
{
return $user->canTarget($model) && $this->defaultDelete($user, $model);
}
}

View File

@ -6,6 +6,7 @@ use App\Extensions\Themes\Theme;
use App\Models; use App\Models;
use App\Models\ApiKey; use App\Models\ApiKey;
use App\Models\Node; use App\Models\Node;
use App\Models\User;
use App\Services\Helpers\SoftwareVersionService; use App\Services\Helpers\SoftwareVersionService;
use Dedoc\Scramble\Scramble; use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\Generator\OpenApi; use Dedoc\Scramble\Support\Generator\OpenApi;
@ -91,6 +92,10 @@ class AppServiceProvider extends ServiceProvider
'success' => Color::Green, 'success' => Color::Green,
'warning' => Color::Amber, 'warning' => Color::Amber,
]); ]);
Gate::before(function (User $user, $ability) {
return $user->isRootAdmin() ? true : null;
});
} }
/** /**

View File

@ -32,6 +32,7 @@ class AdminAcl
public const RESOURCE_DATABASE_HOSTS = 'database_hosts'; public const RESOURCE_DATABASE_HOSTS = 'database_hosts';
public const RESOURCE_SERVER_DATABASES = 'server_databases'; public const RESOURCE_SERVER_DATABASES = 'server_databases';
public const RESOURCE_MOUNTS = 'mounts'; public const RESOURCE_MOUNTS = 'mounts';
public const RESOURCE_ROLES = 'roles';
/** /**
* Determine if an API key has permission to perform a specific read/write operation. * Determine if an API key has permission to perform a specific read/write operation.

View File

@ -14,10 +14,10 @@ class GetUserPermissionsService
*/ */
public function handle(Server $server, User $user): array 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 = ['*']; $permissions = ['*'];
if ($user->root_admin) { if ($user->isRootAdmin()) {
$permissions[] = 'admin.websocket.errors'; $permissions[] = 'admin.websocket.errors';
$permissions[] = 'admin.websocket.install'; $permissions[] = 'admin.websocket.install';
$permissions[] = 'admin.websocket.transfer'; $permissions[] = 'admin.websocket.transfer';

View File

@ -2,6 +2,7 @@
namespace App\Services\Users; namespace App\Services\Users;
use App\Models\Role;
use Ramsey\Uuid\Uuid; use Ramsey\Uuid\Uuid;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Hashing\Hasher; use Illuminate\Contracts\Hashing\Hasher;
@ -39,10 +40,17 @@ class UserCreationService
$data['password'] = $this->hasher->make(str_random(30)); $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, [ $user = User::query()->forceCreate(array_merge($data, [
'uuid' => Uuid::uuid4()->toString(), 'uuid' => Uuid::uuid4()->toString(),
])); ]));
if ($isRootAdmin) {
$user->syncRoles(Role::getRootAdmin());
}
if (isset($generateResetToken)) { if (isset($generateResetToken)) {
$token = $this->passwordBroker->createToken($user); $token = $this->passwordBroker->createToken($user);
} }

View File

@ -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 // the user is a root admin at the moment. In a future release we'll be rolling
// out more specific permissions for keys. // out more specific permissions for keys.
if ($token->key_type === ApiKey::TYPE_ACCOUNT) { if ($token->key_type === ApiKey::TYPE_ACCOUNT) {
return $this->request->user()->root_admin; return $this->request->user()->isRootAdmin();
} }
return AdminAcl::check($token, $resource); return AdminAcl::check($token, $resource);

View File

@ -0,0 +1,23 @@
<?php
namespace App\Transformers\Api\Application;
use Spatie\Permission\Models\Permission;
class RolePermissionTransformer extends BaseTransformer
{
public function getResourceName(): string
{
return 'permissions';
}
public function transform(Permission $model): array
{
return [
'name' => $model->name,
'guard_name' => $model->guard_name,
'created_at' => $model->created_at->toAtomString(),
'updated_at' => $model->updated_at->toAtomString(),
];
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Transformers\Api\Application;
use App\Models\Role;
use League\Fractal\Resource\Collection;
use League\Fractal\Resource\NullResource;
class RoleTransformer extends BaseTransformer
{
protected array $availableIncludes = [
'permissions',
];
/**
* Return the resource name for the JSONAPI output.
*/
public function getResourceName(): string
{
return Role::RESOURCE_NAME;
}
/**
* Transform role into a representation for the application API.
*/
public function transform(Role $model): array
{
return [
'name' => $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');
}
}

View File

@ -2,6 +2,7 @@
namespace App\Transformers\Api\Application; namespace App\Transformers\Api\Application;
use App\Models\Role;
use App\Models\User; use App\Models\User;
use League\Fractal\Resource\Collection; use League\Fractal\Resource\Collection;
use League\Fractal\Resource\NullResource; use League\Fractal\Resource\NullResource;
@ -12,7 +13,10 @@ class UserTransformer extends BaseTransformer
/** /**
* List of resources that can be included. * List of resources that can be included.
*/ */
protected array $availableIncludes = ['servers']; protected array $availableIncludes = [
'servers',
'roles',
];
/** /**
* Return the resource name for the JSONAPI output. * Return the resource name for the JSONAPI output.
@ -36,7 +40,7 @@ class UserTransformer extends BaseTransformer
'first_name' => $user->name_first, 'first_name' => $user->name_first,
'last_name' => $user->name_last, 'last_name' => $user->name_last,
'language' => $user->language, 'language' => $user->language,
'root_admin' => (bool) $user->root_admin, 'root_admin' => $user->isRootAdmin(),
'2fa_enabled' => (bool) $user->use_totp, '2fa_enabled' => (bool) $user->use_totp,
'2fa' => (bool) $user->use_totp, // deprecated, use "2fa_enabled" '2fa' => (bool) $user->use_totp, // deprecated, use "2fa_enabled"
'created_at' => $this->formatTimestamp($user->created_at), '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 $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);
}
} }

View File

@ -113,6 +113,6 @@ class ActivityLogTransformer extends BaseClientTransformer
*/ */
protected function canViewIP(Model $actor = null): 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();
} }
} }

View File

@ -29,8 +29,8 @@ class UserTransformer extends BaseClientTransformer
'last_name' => $user->name_last, 'last_name' => $user->name_last,
'language' => $user->language, 'language' => $user->language,
'image' => 'https://gravatar.com/avatar/' . md5(Str::lower($user->email)), // deprecated 'image' => 'https://gravatar.com/avatar/' . md5(Str::lower($user->email)), // deprecated
'admin' => (bool) $user->root_admin, // deprecated, use "root_admin" 'admin' => $user->isRootAdmin(), // deprecated, use "root_admin"
'root_admin' => (bool) $user->root_admin, 'root_admin' => $user->isRootAdmin(),
'2fa_enabled' => (bool) $user->use_totp, '2fa_enabled' => (bool) $user->use_totp,
'created_at' => $this->formatTimestamp($user->created_at), 'created_at' => $this->formatTimestamp($user->created_at),
'updated_at' => $this->formatTimestamp($user->updated_at), 'updated_at' => $this->formatTimestamp($user->updated_at),

View File

@ -34,6 +34,7 @@
"s1lentium/iptools": "~1.2.0", "s1lentium/iptools": "~1.2.0",
"socialiteproviders/discord": "^4.2", "socialiteproviders/discord": "^4.2",
"spatie/laravel-fractal": "^6.2", "spatie/laravel-fractal": "^6.2",
"spatie/laravel-permission": "^6.9",
"spatie/laravel-query-builder": "^5.8.1", "spatie/laravel-query-builder": "^5.8.1",
"spatie/temporary-directory": "^2.2", "spatie/temporary-directory": "^2.2",
"symfony/http-client": "^7.1", "symfony/http-client": "^7.1",

84
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "443ec1d95b892b261af5481f27b31083", "content-hash": "507ac5b637c51b90e6ae00717fe085cc",
"packages": [ "packages": [
{ {
"name": "abdelhamiderrahmouni/filament-monaco-editor", "name": "abdelhamiderrahmouni/filament-monaco-editor",
@ -7234,6 +7234,88 @@
], ],
"time": "2024-03-20T07:29:11+00:00" "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", "name": "spatie/laravel-query-builder",
"version": "5.8.1", "version": "5.8.1",

13
config/permission.php Normal file
View File

@ -0,0 +1,13 @@
<?php
return [
'models' => [
'permission' => Spatie\Permission\Models\Permission::class,
'role' => \App\Models\Role::class,
],
];

View File

@ -33,19 +33,10 @@ class UserFactory extends Factory
'name_last' => $this->faker->lastName(), 'name_last' => $this->faker->lastName(),
'password' => $password ?: $password = bcrypt('password'), 'password' => $password ?: $password = bcrypt('password'),
'language' => 'en', 'language' => 'en',
'root_admin' => false,
'use_totp' => false, 'use_totp' => false,
'oauth' => [], 'oauth' => [],
'created_at' => Carbon::now(), 'created_at' => Carbon::now(),
'updated_at' => Carbon::now(), 'updated_at' => Carbon::now(),
]; ];
} }
/**
* Indicate that the user is an admin.
*/
public function admin(): static
{
return $this->state(['root_admin' => true]);
}
} }

View File

@ -2,6 +2,7 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\Role;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
@ -12,5 +13,7 @@ class DatabaseSeeder extends Seeder
public function run() public function run()
{ {
$this->call(EggSeeder::class); $this->call(EggSeeder::class);
Role::firstOrCreate(['name' => Role::ROOT_ADMIN]);
} }
} }

View File

@ -0,0 +1,140 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$teams = config('permission.teams');
$tableNames = config('permission.table_names');
$columnNames = config('permission.column_names');
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
if (empty($tableNames)) {
throw new \Exception('Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
}
if ($teams && empty($columnNames['team_foreign_key'] ?? null)) {
throw new \Exception('Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
}
Schema::create($tableNames['permissions'], function (Blueprint $table) {
//$table->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']);
}
};

View File

@ -0,0 +1,35 @@
<?php
use App\Models\Role;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$adminUsers = User::whereRootAdmin(true)->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);
});
}
};

View File

@ -13,7 +13,6 @@ return [
'hint' => 'This is the last root administrator!', 'hint' => 'This is the last root administrator!',
'helper_text' => 'You must have at least one root administrator in your system.', 'helper_text' => 'You must have at least one root administrator in your system.',
], ],
'root_admin' => 'Administrator (Root)',
'language' => [ 'language' => [
'helper_text1' => 'Your language (:state) has not been translated yet!\nBut never fear, you can help fix that by', 'helper_text1' => 'Your language (:state) has not been translated yet!\nBut never fear, you can help fix that by',
'helper_text2' => 'contributing directly here', 'helper_text2' => 'contributing directly here',

View File

@ -27,6 +27,7 @@ interface ExtendedWindow extends Window {
email: string; email: string;
/* eslint-disable camelcase */ /* eslint-disable camelcase */
root_admin: boolean; root_admin: boolean;
admin: boolean;
use_totp: boolean; use_totp: boolean;
language: string; language: string;
updated_at: string; updated_at: string;
@ -46,6 +47,7 @@ const App = () => {
email: PanelUser.email, email: PanelUser.email,
language: PanelUser.language, language: PanelUser.language,
rootAdmin: PanelUser.root_admin, rootAdmin: PanelUser.root_admin,
admin: PanelUser.admin,
useTotp: PanelUser.use_totp, useTotp: PanelUser.use_totp,
createdAt: new Date(PanelUser.created_at), createdAt: new Date(PanelUser.created_at),
updatedAt: new Date(PanelUser.updated_at), updatedAt: new Date(PanelUser.updated_at),

View File

@ -37,7 +37,7 @@ export default () => {
const { t } = useTranslation('strings'); const { t } = useTranslation('strings');
const name = useStoreState((state: ApplicationStore) => state.settings.data!.name); 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 [isLoggingOut, setIsLoggingOut] = useState(false);
const onTriggerLogout = () => { const onTriggerLogout = () => {
@ -69,7 +69,7 @@ export default () => {
<FontAwesomeIcon icon={faLayerGroup} /> <FontAwesomeIcon icon={faLayerGroup} />
</NavLink> </NavLink>
</Tooltip> </Tooltip>
{rootAdmin && ( {isAdmin && (
<Tooltip placement={'bottom'} content={t<string>('admin')}> <Tooltip placement={'bottom'} content={t<string>('admin')}>
<a href={'/admin'} rel={'noreferrer'}> <a href={'/admin'} rel={'noreferrer'}>
<FontAwesomeIcon icon={faCogs} /> <FontAwesomeIcon icon={faCogs} />

View File

@ -7,6 +7,7 @@ export interface UserData {
email: string; email: string;
language: string; language: string;
rootAdmin: boolean; rootAdmin: boolean;
admin: boolean;
useTotp: boolean; useTotp: boolean;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;

View File

@ -19,6 +19,8 @@ Route::prefix('/users')->group(function () {
Route::post('/', [Application\Users\UserController::class, 'store']); Route::post('/', [Application\Users\UserController::class, 'store']);
Route::patch('/{user:id}', [Application\Users\UserController::class, 'update']); 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']); 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}/eggs/{egg_id}', [Application\Mounts\MountController::class, 'deleteEgg']);
Route::delete('/{mount:id}/nodes/{node_id}', [Application\Mounts\MountController::class, 'deleteNode']); 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']);
});

View File

@ -6,6 +6,7 @@ use Illuminate\Http\Request;
use App\Models\User; use App\Models\User;
use PHPUnit\Framework\Assert; use PHPUnit\Framework\Assert;
use App\Models\ApiKey; use App\Models\ApiKey;
use App\Models\Role;
use App\Services\Acl\Api\AdminAcl; use App\Services\Acl\Api\AdminAcl;
use App\Tests\Integration\IntegrationTestCase; use App\Tests\Integration\IntegrationTestCase;
use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\DatabaseTransactions;
@ -67,9 +68,10 @@ abstract class ApplicationApiIntegrationTestCase extends IntegrationTestCase
*/ */
protected function createApiUser(): User protected function createApiUser(): User
{ {
return User::factory()->create([ $user = User::factory()->create();
'root_admin' => true, $user->syncRoles(Role::getRootAdmin());
]);
return $user;
} }
/** /**

View File

@ -38,7 +38,7 @@ class ExternalUserControllerTest extends ApplicationApiIntegrationTestCase
'first_name' => $user->name_first, 'first_name' => $user->name_first,
'last_name' => $user->name_last, 'last_name' => $user->name_last,
'language' => $user->language, 'language' => $user->language,
'root_admin' => (bool) $user->root_admin, 'root_admin' => (bool) $user->isRootAdmin(),
'2fa' => (bool) $user->totp_enabled, '2fa' => (bool) $user->totp_enabled,
'created_at' => $this->formatTimestamp($user->created_at), 'created_at' => $this->formatTimestamp($user->created_at),
'updated_at' => $this->formatTimestamp($user->updated_at), 'updated_at' => $this->formatTimestamp($user->updated_at),

View File

@ -55,7 +55,7 @@ class UserControllerTest extends ApplicationApiIntegrationTestCase
'first_name' => $this->getApiUser()->name_first, 'first_name' => $this->getApiUser()->name_first,
'last_name' => $this->getApiUser()->name_last, 'last_name' => $this->getApiUser()->name_last,
'language' => $this->getApiUser()->language, 'language' => $this->getApiUser()->language,
'root_admin' => $this->getApiUser()->root_admin, 'root_admin' => $this->getApiUser()->isRootAdmin(),
'2fa_enabled' => (bool) $this->getApiUser()->totp_enabled, '2fa_enabled' => (bool) $this->getApiUser()->totp_enabled,
'2fa' => (bool) $this->getApiUser()->totp_enabled, '2fa' => (bool) $this->getApiUser()->totp_enabled,
'created_at' => $this->formatTimestamp($this->getApiUser()->created_at), 'created_at' => $this->formatTimestamp($this->getApiUser()->created_at),
@ -73,7 +73,7 @@ class UserControllerTest extends ApplicationApiIntegrationTestCase
'first_name' => $user->name_first, 'first_name' => $user->name_first,
'last_name' => $user->name_last, 'last_name' => $user->name_last,
'language' => $user->language, 'language' => $user->language,
'root_admin' => (bool) $user->root_admin, 'root_admin' => (bool) $user->isRootAdmin(),
'2fa_enabled' => (bool) $user->totp_enabled, '2fa_enabled' => (bool) $user->totp_enabled,
'2fa' => (bool) $user->totp_enabled, '2fa' => (bool) $user->totp_enabled,
'created_at' => $this->formatTimestamp($user->created_at), 'created_at' => $this->formatTimestamp($user->created_at),

View File

@ -7,6 +7,7 @@ use App\Models\Server;
use App\Models\Subuser; use App\Models\Subuser;
use App\Models\Allocation; use App\Models\Allocation;
use App\Models\Permission; use App\Models\Permission;
use App\Models\Role;
class ClientControllerTest extends ClientApiIntegrationTestCase class ClientControllerTest extends ClientApiIntegrationTestCase
{ {
@ -47,7 +48,7 @@ class ClientControllerTest extends ClientApiIntegrationTestCase
{ {
/** @var \App\Models\User[] $users */ /** @var \App\Models\User[] $users */
$users = User::factory()->times(2)->create(); $users = User::factory()->times(2)->create();
$users[0]->update(['root_admin' => true]); $users[0]->syncRoles(Role::getRootAdmin());
/** @var \App\Models\Server[] $servers */ /** @var \App\Models\Server[] $servers */
$servers = [ $servers = [
@ -225,7 +226,7 @@ class ClientControllerTest extends ClientApiIntegrationTestCase
{ {
/** @var \App\Models\User[] $users */ /** @var \App\Models\User[] $users */
$users = User::factory()->times(4)->create(); $users = User::factory()->times(4)->create();
$users[0]->update(['root_admin' => true]); $users[0]->syncRoles(Role::getRootAdmin());
$servers = [ $servers = [
$this->createServerModel(['user_id' => $users[0]->id]), $this->createServerModel(['user_id' => $users[0]->id]),
@ -260,7 +261,7 @@ class ClientControllerTest extends ClientApiIntegrationTestCase
{ {
/** @var \App\Models\User[] $users */ /** @var \App\Models\User[] $users */
$users = User::factory()->times(4)->create(); $users = User::factory()->times(4)->create();
$users[0]->update(['root_admin' => true]); $users[0]->syncRoles(Role::getRootAdmin());
$servers = [ $servers = [
$this->createServerModel(['user_id' => $users[0]->id]), $this->createServerModel(['user_id' => $users[0]->id]),

View File

@ -7,6 +7,7 @@ use App\Models\Node;
use App\Models\User; use App\Models\User;
use App\Models\Server; use App\Models\Server;
use App\Models\Permission; use App\Models\Permission;
use App\Models\Role;
use App\Models\UserSSHKey; use App\Models\UserSSHKey;
use App\Tests\Integration\IntegrationTestCase; use App\Tests\Integration\IntegrationTestCase;
@ -180,7 +181,7 @@ class SftpAuthenticationControllerTest extends IntegrationTestCase
->assertOk() ->assertOk()
->assertJsonPath('permissions', [Permission::ACTION_FILE_READ, Permission::ACTION_FILE_SFTP]); ->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) $this->postJson('/api/remote/sftp/auth', $data)
->assertOk() ->assertOk()
@ -193,7 +194,7 @@ class SftpAuthenticationControllerTest extends IntegrationTestCase
->assertOk() ->assertOk()
->assertJsonPath('permissions.0', '*'); ->assertJsonPath('permissions.0', '*');
$user->update(['root_admin' => false]); $user->syncRoles();
$this->post('/api/remote/sftp/auth', $data)->assertForbidden(); $this->post('/api/remote/sftp/auth', $data)->assertForbidden();
} }

View File

@ -5,6 +5,7 @@ namespace App\Tests;
use Carbon\Carbon; use Carbon\Carbon;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Spatie\Permission\PermissionRegistrar;
abstract class TestCase extends BaseTestCase abstract class TestCase extends BaseTestCase
{ {
@ -28,6 +29,8 @@ abstract class TestCase extends BaseTestCase
config()->set('app.debug', false); config()->set('app.debug', false);
$this->setKnownUuidFactory(); $this->setKnownUuidFactory();
$this->app->make(PermissionRegistrar::class)->forgetCachedPermissions();
} }
/** /**

View File

@ -35,13 +35,14 @@ trait RequestMockHelpers
/** /**
* Generates a new request user model and also returns the generated model. * 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); $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);
} }
/** /**

View File

@ -4,6 +4,7 @@ namespace App\Tests\Unit\Http\Middleware;
use App\Models\User; use App\Models\User;
use App\Http\Middleware\AdminAuthenticate; use App\Http\Middleware\AdminAuthenticate;
use Mockery;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class AdminAuthenticateTest extends MiddlewareTestCase class AdminAuthenticateTest extends MiddlewareTestCase
@ -13,7 +14,9 @@ class AdminAuthenticateTest extends MiddlewareTestCase
*/ */
public function testAdminsAreAuthenticated(): void 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); $this->request->shouldReceive('user')->withNoArgs()->twice()->andReturn($user);
@ -39,7 +42,9 @@ class AdminAuthenticateTest extends MiddlewareTestCase
{ {
$this->expectException(AccessDeniedHttpException::class); $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); $this->request->shouldReceive('user')->withNoArgs()->twice()->andReturn($user);

View File

@ -27,7 +27,7 @@ class AuthenticateUserTest extends MiddlewareTestCase
{ {
$this->expectException(AccessDeniedHttpException::class); $this->expectException(AccessDeniedHttpException::class);
$this->generateRequestUserModel(['root_admin' => false]); $this->generateRequestUserModel(false);
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); $this->getMiddleware()->handle($this->request, $this->getClosureAssertions());
} }
@ -37,7 +37,7 @@ class AuthenticateUserTest extends MiddlewareTestCase
*/ */
public function testAdminUser(): void public function testAdminUser(): void
{ {
$this->generateRequestUserModel(['root_admin' => true]); $this->generateRequestUserModel(true);
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); $this->getMiddleware()->handle($this->request, $this->getClosureAssertions());
} }