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

View File

@ -42,7 +42,8 @@ class ListDatabaseHosts extends ListRecords
])
->bulkActions([
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 Filament\Actions;
use Filament\Tables\Actions\EditAction;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@ -48,7 +48,8 @@ class ListDatabases extends ListRecords
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete database')),
]),
]);
}

View File

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

View File

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

View File

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

View File

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

View File

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

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')
->searchable(['username', 'email'])
->getOptionLabelFromRecordUsing(fn (User $user) => "$user->email | $user->username " . ($user->root_admin ? '(admin)' : ''))
->getOptionLabelFromRecordUsing(fn (User $user) => "$user->email | $user->username " . ($user->isRootAdmin() ? '(admin)' : ''))
->createOptionForm([
Forms\Components\TextInput::make('username')
->alphaNum()
@ -98,21 +98,6 @@ class CreateServer extends CreateRecord
->hintIcon('tabler-question-mark')
->hintIconTooltip('Providing a user password is optional. New user email will prompt users to create a password the first time they login.')
->password(),
Forms\Components\ToggleButtons::make('root_admin')
->label('Administrator (Root)')
->options([
false => 'No',
true => 'Admin',
])
->colors([
false => 'primary',
true => 'danger',
])
->inline()
->required()
->default(false)
->hidden(),
])
->createOptionUsing(function ($data) {
resolve(UserCreationService::class)->handle($data);

View File

@ -4,6 +4,7 @@ namespace App\Filament\Resources\ServerResource\Pages;
use App\Filament\Resources\ServerResource;
use App\Models\Server;
use App\Models\User;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\CreateAction;
@ -76,7 +77,13 @@ class ListServers extends ListRecords
->actions([
Tables\Actions\Action::make('View')
->icon('tabler-terminal')
->url(fn (Server $server) => "/server/$server->uuid_short"),
->url(fn (Server $server) => "/server/$server->uuid_short")
->visible(function (Server $server) {
/** @var User $user */
$user = auth()->user();
return $user->isRootAdmin() || $user->id === $server->owner_id;
}),
Tables\Actions\EditAction::make(),
])
->emptyStateIcon('tabler-brand-docker')

View File

@ -3,13 +3,16 @@
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use App\Services\Exceptions\FilamentExceptionHandler;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
use App\Models\Role;
use App\Models\User;
use Filament\Forms;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Hash;
class EditUser extends EditRecord
@ -20,54 +23,33 @@ class EditUser extends EditRecord
return $form
->schema([
Section::make()->schema([
Forms\Components\TextInput::make('username')->required()->maxLength(255),
Forms\Components\TextInput::make('email')->email()->required()->maxLength(255),
Forms\Components\TextInput::make('password')
TextInput::make('username')->required()->maxLength(255),
TextInput::make('email')->email()->required()->maxLength(255),
TextInput::make('password')
->dehydrateStateUsing(fn (string $state): string => Hash::make($state))
->dehydrated(fn (?string $state): bool => filled($state))
->required(fn (string $operation): bool => $operation === 'create')
->password(),
Forms\Components\ToggleButtons::make('root_admin')
->label('Administrator (Root)')
->options([
false => 'No',
true => 'Admin',
])
->colors([
false => 'primary',
true => 'danger',
])
->disableOptionWhen(function (string $operation, $value, User $user) {
if ($operation !== 'edit' || $value) {
return false;
}
return $user->isLastRootAdmin();
})
->hint(fn (User $user) => $user->isLastRootAdmin() ? 'This is the last root administrator!' : '')
->helperText(fn (User $user) => $user->isLastRootAdmin() ? 'You must have at least one root administrator in your system.' : '')
->hintColor('warning')
->inline()
->required()
->default(false),
Forms\Components\Hidden::make('skipValidation')->default(true),
Forms\Components\Select::make('language')
Select::make('language')
->required()
->hidden()
->default('en')
->options(fn (User $user) => $user->getAvailableLanguages()),
Hidden::make('skipValidation')->default(true),
CheckboxList::make('roles')
->disabled(fn (User $user) => $user->id === auth()->user()->id)
->disableOptionWhen(fn (string $value): bool => $value == Role::getRootAdmin()->id)
->relationship('roles', 'name')
->label('Admin Roles')
->columnSpanFull()
->bulkToggleable(false),
])->columns(),
]);
}
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
DeleteAction::make()
->label(fn (User $user) => auth()->user()->id === $user->id ? 'Can\'t Delete Yourself' : ($user->servers()->count() > 0 ? 'User Has Servers' : 'Delete'))
->disabled(fn (User $user) => auth()->user()->id === $user->id || $user->servers()->count() > 0),
$this->getSaveFormAction()->formId('form'),
@ -78,9 +60,4 @@ class EditUser extends EditRecord
{
return [];
}
public function exception($exception, $stopPropagation): void
{
(new FilamentExceptionHandler())->handle($exception, $stopPropagation);
}
}

View File

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

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\UpdateUserRequest;
use App\Http\Controllers\Api\Application\ApplicationApiController;
use App\Http\Requests\Api\Application\Users\AssignUserRolesRequest;
class UserController extends ApplicationApiController
{
@ -75,6 +76,19 @@ class UserController extends ApplicationApiController
return $response->toArray();
}
/**
* Assign roles to a user.
*/
public function roles(AssignUserRolesRequest $request, User $user): array
{
$user->syncRoles($request->input('roles'));
$response = $this->fractal->item($user)
->transformWith($this->getTransformer(UserTransformer::class));
return $response->toArray();
}
/**
* Store a new user on the system. Returns the created user and an HTTP/201
* header on successful creation.

View File

@ -48,7 +48,7 @@ class ClientController extends ClientApiController
if (in_array($type, ['admin', 'admin-all'])) {
// If they aren't an admin but want all the admin servers don't fail the request, just
// make it a query that will never return any results back.
if (!$user->root_admin) {
if (!$user->isRootAdmin()) {
$builder->whereRaw('1 = 2');
} else {
$builder = $type === 'admin-all'

View File

@ -13,6 +13,7 @@ use Illuminate\Database\Query\JoinClause;
use App\Http\Requests\Api\Client\ClientApiRequest;
use App\Transformers\Api\Client\ActivityLogTransformer;
use App\Http\Controllers\Api\Client\ClientApiController;
use App\Models\Role;
class ActivityLogController extends ClientApiController
{
@ -32,15 +33,16 @@ class ActivityLogController extends ClientApiController
// We could do this with a query and a lot of joins, but that gets pretty
// painful so for now we'll execute a simpler query.
$subusers = $server->subusers()->pluck('user_id')->merge([$server->owner_id]);
$rootAdmins = Role::getRootAdmin()->users()->pluck('id');
$builder->select('activity_logs.*')
->leftJoin('users', function (JoinClause $join) {
$join->on('users.id', 'activity_logs.actor_id')
->where('activity_logs.actor_type', (new User())->getMorphClass());
})
->where(function (Builder $builder) use ($subusers) {
->where(function (Builder $builder) use ($subusers, $rootAdmins) {
$builder->whereNull('users.id')
->orWhere('users.root_admin', 0)
->orWhereNotIn('users.id', $rootAdmins)
->orWhereIn('users.id', $subusers);
});
})

View File

@ -140,7 +140,7 @@ class SftpAuthenticationController extends Controller
*/
protected function validateSftpAccess(User $user, Server $server): void
{
if (!$user->root_admin && $server->owner_id !== $user->id) {
if (!$user->isRootAdmin() && $server->owner_id !== $user->id) {
$permissions = $this->permissions->handle($server, $user);
if (!in_array(Permission::ACTION_FILE_SFTP, $permissions)) {

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -56,7 +56,7 @@ abstract class SubuserRequest extends ClientApiRequest
$server = $this->route()->parameter('server');
// If we are a root admin or the server owner, no need to perform these checks.
if ($user->root_admin || $user->id === $server->owner_id) {
if ($user->isRootAdmin() || $user->id === $server->owner_id) {
return;
}

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\CanResetPassword as CanResetPasswordContract;
use App\Notifications\SendPasswordReset as ResetPasswordNotification;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Model as IlluminateModel;
use Spatie\Permission\Traits\HasRoles;
/**
* App\Models\User.
@ -40,7 +43,6 @@ use App\Notifications\SendPasswordReset as ResetPasswordNotification;
* @property string|null $remember_token
* @property string $language
* @property string $timezone
* @property bool $root_admin
* @property bool $use_totp
* @property string|null $totp_secret
* @property \Illuminate\Support\Carbon|null $totp_authenticated_at
@ -77,7 +79,6 @@ use App\Notifications\SendPasswordReset as ResetPasswordNotification;
* @method static Builder|User whereNameLast($value)
* @method static Builder|User wherePassword($value)
* @method static Builder|User whereRememberToken($value)
* @method static Builder|User whereRootAdmin($value)
* @method static Builder|User whereTotpAuthenticatedAt($value)
* @method static Builder|User whereTotpSecret($value)
* @method static Builder|User whereUpdatedAt($value)
@ -94,6 +95,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
use AvailableLanguages;
use CanResetPassword;
use HasAccessTokens;
use HasRoles;
use Notifiable;
public const USER_LEVEL_USER = 0;
@ -131,7 +133,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'totp_secret',
'totp_authenticated_at',
'gravatar',
'root_admin',
'oauth',
];
@ -145,7 +146,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
*/
protected $attributes = [
'external_id' => null,
'root_admin' => false,
'language' => 'en',
'timezone' => 'UTC',
'use_totp' => false,
@ -166,7 +166,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'name_first' => 'nullable|string|between:0,255',
'name_last' => 'nullable|string|between:0,255',
'password' => 'sometimes|nullable|string',
'root_admin' => 'boolean',
'language' => 'string',
'timezone' => 'string',
'use_totp' => 'boolean',
@ -177,7 +176,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
protected function casts(): array
{
return [
'root_admin' => 'boolean',
'use_totp' => 'boolean',
'gravatar' => 'boolean',
'totp_authenticated_at' => 'datetime',
@ -226,7 +224,10 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
*/
public function toReactObject(): array
{
return collect($this->toArray())->except(['id', 'external_id'])->toArray();
return array_merge(collect($this->toArray())->except(['id', 'external_id'])->toArray(), [
'root_admin' => $this->isRootAdmin(),
'admin' => $this->canAccessPanel(Filament::getPanel('admin')),
]);
}
/**
@ -315,7 +316,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
protected function checkPermission(Server $server, string $permission = ''): bool
{
if ($this->root_admin || $server->owner_id === $this->id) {
if ($this->isRootAdmin() || $server->owner_id === $this->id) {
return true;
}
@ -351,14 +352,23 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
public function isLastRootAdmin(): bool
{
$rootAdmins = User::query()->where('root_admin', true)->limit(2)->get();
$rootAdmins = User::all()->filter(fn ($user) => $user->isRootAdmin());
return once(fn () => $rootAdmins->count() === 1 && $rootAdmins->first()->is($this));
}
public function isRootAdmin(): bool
{
return $this->hasRole(Role::ROOT_ADMIN);
}
public function canAccessPanel(Panel $panel): bool
{
return $this->root_admin;
if ($this->isRootAdmin()) {
return true;
}
return $this->roles()->count() >= 1 && $this->getAllPermissions()->count() >= 1;
}
public function getFilamentName(): string
@ -370,4 +380,13 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
{
return 'https://gravatar.com/avatar/' . md5(strtolower($this->email));
}
public function canTarget(IlluminateModel $user): bool
{
if ($this->isRootAdmin()) {
return true;
}
return $user instanceof User && !$user->isRootAdmin();
}
}

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;
use App\Models\User;
class EggPolicy
{
public function create(User $user): bool
{
return true;
}
use DefaultPolicies;
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;
use App\Models\User;
use App\Models\Server;
use App\Models\User;
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();
if (!$subuser || empty($permission)) {
return false;
// For "viewAny" the $server param is the class name
if (is_string($server)) {
return null;
}
return in_array($permission, $subuser->permissions);
}
/**
* Runs before any of the functions are called. Used to determine if user is root admin, if so, ignore permissions.
*/
public function before(User $user, string $ability, Server $server): bool
{
if ($user->root_admin || $server->owner_id === $user->id) {
// Owner has full server permissions
if ($server->owner_id === $user->id) {
return true;
}
return $this->checkPermission($user, $server, $ability);
$subuser = $server->subusers->where('user_id', $user->id)->first();
// If the user is a subuser check their permissions
if ($subuser) {
return in_array($ability, $subuser->permissions);
}
// Return null to let default policies take over
return null;
}
/**

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

View File

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

View File

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

View File

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

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
// out more specific permissions for keys.
if ($token->key_type === ApiKey::TYPE_ACCOUNT) {
return $this->request->user()->root_admin;
return $this->request->user()->isRootAdmin();
}
return AdminAcl::check($token, $resource);

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;
use App\Models\Role;
use App\Models\User;
use League\Fractal\Resource\Collection;
use League\Fractal\Resource\NullResource;
@ -12,7 +13,10 @@ class UserTransformer extends BaseTransformer
/**
* List of resources that can be included.
*/
protected array $availableIncludes = ['servers'];
protected array $availableIncludes = [
'servers',
'roles',
];
/**
* Return the resource name for the JSONAPI output.
@ -36,7 +40,7 @@ class UserTransformer extends BaseTransformer
'first_name' => $user->name_first,
'last_name' => $user->name_last,
'language' => $user->language,
'root_admin' => (bool) $user->root_admin,
'root_admin' => $user->isRootAdmin(),
'2fa_enabled' => (bool) $user->use_totp,
'2fa' => (bool) $user->use_totp, // deprecated, use "2fa_enabled"
'created_at' => $this->formatTimestamp($user->created_at),
@ -59,4 +63,20 @@ class UserTransformer extends BaseTransformer
return $this->collection($user->getRelation('servers'), $this->makeTransformer(ServerTransformer::class), 'server');
}
/**
* Return the roles associated with this user.
*
* @throws \App\Exceptions\Transformer\InvalidTransformerLevelException
*/
public function includeRoles(User $user): Collection|NullResource
{
if (!$this->authorize(AdminAcl::RESOURCE_ROLES)) {
return $this->null();
}
$user->loadMissing('roles');
return $this->collection($user->getRelation('roles'), $this->makeTransformer(RoleTransformer::class), Role::RESOURCE_NAME);
}
}

View File

@ -113,6 +113,6 @@ class ActivityLogTransformer extends BaseClientTransformer
*/
protected function canViewIP(Model $actor = null): bool
{
return $actor?->is($this->request->user()) || $this->request->user()->root_admin;
return $actor?->is($this->request->user()) || $this->request->user()->isRootAdmin();
}
}

View File

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

View File

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

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",
"This file is @generated automatically"
],
"content-hash": "443ec1d95b892b261af5481f27b31083",
"content-hash": "507ac5b637c51b90e6ae00717fe085cc",
"packages": [
{
"name": "abdelhamiderrahmouni/filament-monaco-editor",
@ -7234,6 +7234,88 @@
],
"time": "2024-03-20T07:29:11+00:00"
},
{
"name": "spatie/laravel-permission",
"version": "6.9.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-permission.git",
"reference": "fe973a58b44380d0e8620107259b7bda22f70408"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-permission/zipball/fe973a58b44380d0e8620107259b7bda22f70408",
"reference": "fe973a58b44380d0e8620107259b7bda22f70408",
"shasum": ""
},
"require": {
"illuminate/auth": "^8.12|^9.0|^10.0|^11.0",
"illuminate/container": "^8.12|^9.0|^10.0|^11.0",
"illuminate/contracts": "^8.12|^9.0|^10.0|^11.0",
"illuminate/database": "^8.12|^9.0|^10.0|^11.0",
"php": "^8.0"
},
"require-dev": {
"laravel/passport": "^11.0|^12.0",
"orchestra/testbench": "^6.23|^7.0|^8.0|^9.0",
"phpunit/phpunit": "^9.4|^10.1"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "6.x-dev",
"dev-master": "6.x-dev"
},
"laravel": {
"providers": [
"Spatie\\Permission\\PermissionServiceProvider"
]
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Spatie\\Permission\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Permission handling for Laravel 8.0 and up",
"homepage": "https://github.com/spatie/laravel-permission",
"keywords": [
"acl",
"laravel",
"permission",
"permissions",
"rbac",
"roles",
"security",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/laravel-permission/issues",
"source": "https://github.com/spatie/laravel-permission/tree/6.9.0"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2024-06-22T23:04:52+00:00"
},
{
"name": "spatie/laravel-query-builder",
"version": "5.8.1",

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(),
'password' => $password ?: $password = bcrypt('password'),
'language' => 'en',
'root_admin' => false,
'use_totp' => false,
'oauth' => [],
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
];
}
/**
* Indicate that the user is an admin.
*/
public function admin(): static
{
return $this->state(['root_admin' => true]);
}
}

View File

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

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!',
'helper_text' => 'You must have at least one root administrator in your system.',
],
'root_admin' => 'Administrator (Root)',
'language' => [
'helper_text1' => 'Your language (:state) has not been translated yet!\nBut never fear, you can help fix that by',
'helper_text2' => 'contributing directly here',

View File

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

View File

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

View File

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

View File

@ -19,6 +19,8 @@ Route::prefix('/users')->group(function () {
Route::post('/', [Application\Users\UserController::class, 'store']);
Route::patch('/{user:id}', [Application\Users\UserController::class, 'update']);
Route::patch('/{user:id}/roles', [Application\Users\UserController::class, 'roles']);
Route::delete('/{user:id}', [Application\Users\UserController::class, 'delete']);
});
@ -141,3 +143,22 @@ Route::prefix('mounts')->group(function () {
Route::delete('/{mount:id}/eggs/{egg_id}', [Application\Mounts\MountController::class, 'deleteEgg']);
Route::delete('/{mount:id}/nodes/{node_id}', [Application\Mounts\MountController::class, 'deleteNode']);
});
/*
|--------------------------------------------------------------------------
| Role Controller Routes
|--------------------------------------------------------------------------
|
| Endpoint: /api/application/roles
|
*/
Route::group(['prefix' => '/roles'], function () {
Route::get('/', [Application\Roles\RoleController::class, 'index'])->name('api.application.roles');
Route::get('/{role:id}', [Application\Roles\RoleController::class, 'view'])->name('api.application.roles.view');
Route::post('/', [Application\Roles\RoleController::class, 'store']);
Route::patch('/{role:id}', [Application\Roles\RoleController::class, 'update']);
Route::delete('/{role:id}', [Application\Roles\RoleController::class, 'delete']);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -35,13 +35,14 @@ trait RequestMockHelpers
/**
* Generates a new request user model and also returns the generated model.
*/
public function generateRequestUserModel(array $args = []): User
public function generateRequestUserModel(bool $isRootAdmin, array $args = []): void
{
/** @var \App\Models\User $user */
$user = User::factory()->make($args);
$this->setRequestUserModel($user);
$user = m::mock($user)->makePartial();
$user->shouldReceive('isRootAdmin')->andReturn($isRootAdmin);
return $user;
/** @var User|Mock $user */
$this->setRequestUserModel($user);
}
/**

View File

@ -4,6 +4,7 @@ namespace App\Tests\Unit\Http\Middleware;
use App\Models\User;
use App\Http\Middleware\AdminAuthenticate;
use Mockery;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class AdminAuthenticateTest extends MiddlewareTestCase
@ -13,7 +14,9 @@ class AdminAuthenticateTest extends MiddlewareTestCase
*/
public function testAdminsAreAuthenticated(): void
{
$user = User::factory()->make(['root_admin' => 1]);
$user = User::factory()->make();
$user = Mockery::mock($user)->makePartial();
$user->shouldReceive('isRootAdmin')->andReturnTrue();
$this->request->shouldReceive('user')->withNoArgs()->twice()->andReturn($user);
@ -39,7 +42,9 @@ class AdminAuthenticateTest extends MiddlewareTestCase
{
$this->expectException(AccessDeniedHttpException::class);
$user = User::factory()->make(['root_admin' => 0]);
$user = User::factory()->make();
$user = Mockery::mock($user)->makePartial();
$user->shouldReceive('isRootAdmin')->andReturnFalse();
$this->request->shouldReceive('user')->withNoArgs()->twice()->andReturn($user);

View File

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