From 03745eb4be12314b0ff1ef4832856196700d9414 Mon Sep 17 00:00:00 2001 From: Boy132 Date: Mon, 5 May 2025 12:58:55 +0200 Subject: [PATCH] Allow to assign nodes to roles (node ownership) (#1231) * allow to assign nodes to roles * fix typo * fix node policy * small ui improvements * add missing translation * make phpstan happy * fix migration on mysql * also restrict mounts & database hosts to allowed nodes * fix migration on mysql v2 * changes from review * fix hasManyThrough * change `accessibleNodes` to builder Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com> --------- Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com> --- .../Admin/Resources/DatabaseHostResource.php | 14 ++++++-- .../Pages/CreateDatabaseHost.php | 3 +- .../Admin/Resources/MountResource.php | 14 ++++++-- app/Filament/Admin/Resources/NodeResource.php | 10 +++++- app/Filament/Admin/Resources/RoleResource.php | 14 ++++++++ .../Admin/Resources/ServerResource.php | 12 ++++++- .../ServerResource/Pages/CreateServer.php | 10 ++++-- .../ServerResource/Pages/EditServer.php | 2 +- app/Models/Node.php | 7 ++++ app/Models/NodeRole.php | 12 +++++++ app/Models/Role.php | 8 +++++ app/Models/User.php | 31 ++++++++++++++++-- app/Policies/DatabaseHostPolicy.php | 19 +++++++++++ app/Policies/MountPolicy.php | 19 +++++++++++ app/Policies/NodePolicy.php | 17 ++++++++++ app/Policies/ServerPolicy.php | 5 +++ ...25_04_08_113556_create_node_role_table.php | 32 +++++++++++++++++++ lang/en/admin/role.php | 2 ++ 18 files changed, 219 insertions(+), 12 deletions(-) create mode 100644 app/Models/NodeRole.php create mode 100644 database/migrations/2025_04_08_113556_create_node_role_table.php diff --git a/app/Filament/Admin/Resources/DatabaseHostResource.php b/app/Filament/Admin/Resources/DatabaseHostResource.php index 421d39c6b..0b6fb29b5 100644 --- a/app/Filament/Admin/Resources/DatabaseHostResource.php +++ b/app/Filament/Admin/Resources/DatabaseHostResource.php @@ -16,6 +16,7 @@ use Filament\Tables\Actions\EditAction; use Filament\Tables\Actions\ViewAction; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; class DatabaseHostResource extends Resource { @@ -27,7 +28,7 @@ class DatabaseHostResource extends Resource public static function getNavigationBadge(): ?string { - return static::getModel()::count() ?: null; + return (string) static::getEloquentQuery()->count() ?: null; } public static function getNavigationLabel(): string @@ -144,7 +145,7 @@ class DatabaseHostResource extends Resource ->preload() ->helperText(trans('admin/databasehost.linked_nodes_help')) ->label(trans('admin/databasehost.linked_nodes')) - ->relationship('nodes', 'name'), + ->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id'))), ]), ]); } @@ -158,4 +159,13 @@ class DatabaseHostResource extends Resource 'edit' => Pages\EditDatabaseHost::route('/{record}/edit'), ]; } + + public static function getEloquentQuery(): Builder + { + $query = parent::getEloquentQuery(); + + return $query->whereHas('nodes', function (Builder $query) { + $query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id')); + })->orDoesntHave('nodes'); + } } diff --git a/app/Filament/Admin/Resources/DatabaseHostResource/Pages/CreateDatabaseHost.php b/app/Filament/Admin/Resources/DatabaseHostResource/Pages/CreateDatabaseHost.php index 2c44247cf..ff876440f 100644 --- a/app/Filament/Admin/Resources/DatabaseHostResource/Pages/CreateDatabaseHost.php +++ b/app/Filament/Admin/Resources/DatabaseHostResource/Pages/CreateDatabaseHost.php @@ -17,6 +17,7 @@ use Filament\Notifications\Notification; use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord\Concerns\HasWizard; use Filament\Support\Exceptions\Halt; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\HtmlString; use Illuminate\Support\Str; @@ -145,7 +146,7 @@ class CreateDatabaseHost extends CreateRecord ->preload() ->helperText(trans('admin/databasehost.linked_nodes_help')) ->label(trans('admin/databasehost.linked_nodes')) - ->relationship('nodes', 'name'), + ->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id'))), ]), ]; } diff --git a/app/Filament/Admin/Resources/MountResource.php b/app/Filament/Admin/Resources/MountResource.php index 6ad032e5a..7336cb741 100644 --- a/app/Filament/Admin/Resources/MountResource.php +++ b/app/Filament/Admin/Resources/MountResource.php @@ -18,6 +18,7 @@ use Filament\Tables\Actions\EditAction; use Filament\Tables\Actions\ViewAction; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; class MountResource extends Resource { @@ -44,7 +45,7 @@ class MountResource extends Resource public static function getNavigationBadge(): ?string { - return static::getModel()::count() ?: null; + return (string) static::getEloquentQuery()->count() ?: null; } public static function getNavigationGroup(): ?string @@ -147,7 +148,7 @@ class MountResource extends Resource ->preload(), Select::make('nodes')->multiple() ->label(trans('admin/mount.nodes')) - ->relationship('nodes', 'name') + ->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id'))) ->searchable(['name', 'fqdn']) ->preload(), ]), @@ -170,4 +171,13 @@ class MountResource extends Resource 'edit' => Pages\EditMount::route('/{record}/edit'), ]; } + + public static function getEloquentQuery(): Builder + { + $query = parent::getEloquentQuery(); + + return $query->whereHas('nodes', function (Builder $query) { + $query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id')); + })->orDoesntHave('nodes'); + } } diff --git a/app/Filament/Admin/Resources/NodeResource.php b/app/Filament/Admin/Resources/NodeResource.php index 0dbfa408d..3c1169cfe 100644 --- a/app/Filament/Admin/Resources/NodeResource.php +++ b/app/Filament/Admin/Resources/NodeResource.php @@ -6,6 +6,7 @@ use App\Filament\Admin\Resources\NodeResource\Pages; use App\Filament\Admin\Resources\NodeResource\RelationManagers; use App\Models\Node; use Filament\Resources\Resource; +use Illuminate\Database\Eloquent\Builder; class NodeResource extends Resource { @@ -37,7 +38,7 @@ class NodeResource extends Resource public static function getNavigationBadge(): ?string { - return static::getModel()::count() ?: null; + return (string) static::getEloquentQuery()->count() ?: null; } public static function getRelations(): array @@ -56,4 +57,11 @@ class NodeResource extends Resource 'edit' => Pages\EditNode::route('/{record}/edit'), ]; } + + public static function getEloquentQuery(): Builder + { + $query = parent::getEloquentQuery(); + + return $query->whereIn('id', auth()->user()->accessibleNodes()->pluck('id')); + } } diff --git a/app/Filament/Admin/Resources/RoleResource.php b/app/Filament/Admin/Resources/RoleResource.php index 0f9140cf8..db3050574 100644 --- a/app/Filament/Admin/Resources/RoleResource.php +++ b/app/Filament/Admin/Resources/RoleResource.php @@ -10,6 +10,7 @@ use Filament\Forms\Components\Component; use Filament\Forms\Components\Fieldset; use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Section; +use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Form; use Filament\Forms\Get; @@ -69,6 +70,11 @@ class RoleResource extends Resource ->badge() ->counts('permissions') ->formatStateUsing(fn (Role $role, $state) => $role->isRootAdmin() ? trans('admin/role.all') : $state), + TextColumn::make('nodes.name') + ->icon('tabler-server-2') + ->label(trans('admin/role.nodes')) + ->badge() + ->placeholder(trans('admin/role.all')), TextColumn::make('users_count') ->label(trans('admin/role.users')) ->counts('users') @@ -125,6 +131,14 @@ class RoleResource extends Resource ->label(trans('admin/role.permissions')) ->content(trans('admin/role.root_admin', ['role' => Role::ROOT_ADMIN])) ->visible(fn (Get $get) => $get('name') === Role::ROOT_ADMIN), + Select::make('nodes') + ->label(trans('admin/role.nodes')) + ->multiple() + ->relationship('nodes', 'name') + ->searchable(['name', 'fqdn']) + ->preload() + ->hint(trans('admin/role.nodes_hint')) + ->hidden(fn (Get $get) => $get('name') === Role::ROOT_ADMIN), ]); } diff --git a/app/Filament/Admin/Resources/ServerResource.php b/app/Filament/Admin/Resources/ServerResource.php index 0fe7d9bb1..f3073a036 100644 --- a/app/Filament/Admin/Resources/ServerResource.php +++ b/app/Filament/Admin/Resources/ServerResource.php @@ -5,6 +5,7 @@ namespace App\Filament\Admin\Resources; use App\Filament\Admin\Resources\ServerResource\Pages; use App\Models\Server; use Filament\Resources\Resource; +use Illuminate\Database\Eloquent\Builder; class ServerResource extends Resource { @@ -36,7 +37,7 @@ class ServerResource extends Resource public static function getNavigationBadge(): ?string { - return static::getModel()::count() ?: null; + return (string) static::getEloquentQuery()->count() ?: null; } public static function getPages(): array @@ -47,4 +48,13 @@ class ServerResource extends Resource 'edit' => Pages\EditServer::route('/{record}/edit'), ]; } + + public static function getEloquentQuery(): Builder + { + $query = parent::getEloquentQuery(); + + return $query->whereHas('node', function (Builder $query) { + $query->whereIn('id', auth()->user()->accessibleNodes()->pluck('id')); + }); + } } diff --git a/app/Filament/Admin/Resources/ServerResource/Pages/CreateServer.php b/app/Filament/Admin/Resources/ServerResource/Pages/CreateServer.php index 664f34370..1fa997377 100644 --- a/app/Filament/Admin/Resources/ServerResource/Pages/CreateServer.php +++ b/app/Filament/Admin/Resources/ServerResource/Pages/CreateServer.php @@ -109,14 +109,20 @@ class CreateServer extends CreateRecord ->disabledOn('edit') ->prefixIcon('tabler-server-2') ->selectablePlaceholder(false) - ->default(fn () => ($this->node = Node::query()->latest()->first())?->id) + ->default(function () { + /** @var ?Node $latestNode */ + $latestNode = auth()->user()->accessibleNodes()->latest()->first(); + $this->node = $latestNode; + + return $this->node?->id; + }) ->columnSpan([ 'default' => 1, 'sm' => 2, 'md' => 2, ]) ->live() - ->relationship('node', 'name') + ->relationship('node', 'name', fn (Builder $query) => $query->whereIn('id', auth()->user()->accessibleNodes()->pluck('id'))) ->searchable() ->preload() ->afterStateUpdated(function (Set $set, $state) { diff --git a/app/Filament/Admin/Resources/ServerResource/Pages/EditServer.php b/app/Filament/Admin/Resources/ServerResource/Pages/EditServer.php index c0ddfbbb5..15dc35e55 100644 --- a/app/Filament/Admin/Resources/ServerResource/Pages/EditServer.php +++ b/app/Filament/Admin/Resources/ServerResource/Pages/EditServer.php @@ -177,7 +177,7 @@ class EditServer extends EditRecord ->maxLength(255), Select::make('node_id') ->label(trans('admin/server.node')) - ->relationship('node', 'name') + ->relationship('node', 'name', fn (Builder $query) => $query->whereIn('id', auth()->user()->accessibleNodes()->pluck('id'))) ->columnSpan([ 'default' => 2, 'sm' => 1, diff --git a/app/Models/Node.php b/app/Models/Node.php index 1b1cf68a5..c0e07ab48 100644 --- a/app/Models/Node.php +++ b/app/Models/Node.php @@ -49,6 +49,8 @@ use Symfony\Component\Yaml\Yaml; * @property int|null $servers_count * @property \App\Models\Allocation[]|\Illuminate\Database\Eloquent\Collection $allocations * @property int|null $allocations_count + * @property \App\Models\Role[]|\Illuminate\Database\Eloquent\Collection $roles + * @property int|null $roles_count */ class Node extends Model implements Validatable { @@ -268,6 +270,11 @@ class Node extends Model implements Validatable return $this->belongsToMany(DatabaseHost::class); } + public function roles(): HasManyThrough + { + return $this->hasManyThrough(Role::class, NodeRole::class, 'node_id', 'id', 'id', 'role_id'); + } + /** * Returns a boolean if the node is viable for an additional server to be placed on it. */ diff --git a/app/Models/NodeRole.php b/app/Models/NodeRole.php new file mode 100644 index 000000000..f9fe1e85c --- /dev/null +++ b/app/Models/NodeRole.php @@ -0,0 +1,12 @@ +belongsToMany(Node::class, NodeRole::class); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 10e56427f..c8d8800b8 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -286,6 +286,22 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac }); } + public function accessibleNodes(): Builder + { + // Root admins can access all nodes + if ($this->isRootAdmin()) { + return Node::query(); + } + + // Check if there are no restrictions from any role + $roleIds = $this->roles()->pluck('id'); + if (!NodeRole::whereIn('role_id', $roleIds)->exists()) { + return Node::query(); + } + + return Node::whereHas('roles', fn (Builder $builder) => $builder->whereIn('roles.id', $roleIds)); + } + public function subusers(): HasMany { return $this->hasMany(Subuser::class); @@ -390,13 +406,24 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac return $provider?->get($this); } - public function canTarget(Model $user): bool + public function canTarget(Model $model): bool { + // Root admins can target everyone and everything if ($this->isRootAdmin()) { return true; } - return $user instanceof User && !$user->isRootAdmin(); + // Make sure normal admins can't target root admins + if ($model instanceof User) { + return !$model->isRootAdmin(); + } + + // Make sure the user can only target accessible nodes + if ($model instanceof Node) { + return $this->accessibleNodes()->where('id', $model->id)->exists(); + } + + return false; } public function getTenants(Panel $panel): array|Collection diff --git a/app/Policies/DatabaseHostPolicy.php b/app/Policies/DatabaseHostPolicy.php index b93954064..75eb8386d 100644 --- a/app/Policies/DatabaseHostPolicy.php +++ b/app/Policies/DatabaseHostPolicy.php @@ -2,9 +2,28 @@ namespace App\Policies; +use App\Models\DatabaseHost; +use App\Models\User; + class DatabaseHostPolicy { use DefaultPolicies; protected string $modelName = 'databasehost'; + + public function before(User $user, string $ability, string|DatabaseHost $databaseHost): ?bool + { + // For "viewAny" the $databaseHost param is the class name + if (is_string($databaseHost)) { + return null; + } + + foreach ($databaseHost->nodes as $node) { + if (!$user->canTarget($node)) { + return false; + } + } + + return null; + } } diff --git a/app/Policies/MountPolicy.php b/app/Policies/MountPolicy.php index 4f9d58b63..9495dd08d 100644 --- a/app/Policies/MountPolicy.php +++ b/app/Policies/MountPolicy.php @@ -2,9 +2,28 @@ namespace App\Policies; +use App\Models\Mount; +use App\Models\User; + class MountPolicy { use DefaultPolicies; protected string $modelName = 'mount'; + + public function before(User $user, string $ability, string|Mount $mount): ?bool + { + // For "viewAny" the $mount param is the class name + if (is_string($mount)) { + return null; + } + + foreach ($mount->nodes as $node) { + if (!$user->canTarget($node)) { + return false; + } + } + + return null; + } } diff --git a/app/Policies/NodePolicy.php b/app/Policies/NodePolicy.php index 8f23cc666..6a459155c 100644 --- a/app/Policies/NodePolicy.php +++ b/app/Policies/NodePolicy.php @@ -2,9 +2,26 @@ namespace App\Policies; +use App\Models\Node; +use App\Models\User; + class NodePolicy { use DefaultPolicies; protected string $modelName = 'node'; + + public function before(User $user, string $ability, string|Node $node): ?bool + { + // For "viewAny" the $node param is the class name + if (is_string($node)) { + return null; + } + + if (!$user->canTarget($node)) { + return false; + } + + return null; + } } diff --git a/app/Policies/ServerPolicy.php b/app/Policies/ServerPolicy.php index 48d4e5cc3..b9fe590f8 100644 --- a/app/Policies/ServerPolicy.php +++ b/app/Policies/ServerPolicy.php @@ -21,6 +21,11 @@ class ServerPolicy return null; } + // Make sure user can target node of the server + if (!$user->canTarget($server->node)) { + return false; + } + // Owner has full server permissions if ($server->owner_id === $user->id) { return true; diff --git a/database/migrations/2025_04_08_113556_create_node_role_table.php b/database/migrations/2025_04_08_113556_create_node_role_table.php new file mode 100644 index 000000000..42f51c73a --- /dev/null +++ b/database/migrations/2025_04_08_113556_create_node_role_table.php @@ -0,0 +1,32 @@ +unsignedInteger('node_id'); + $table->unsignedBigInteger('role_id'); + + $table->unique(['node_id', 'role_id']); + + $table->foreign('node_id')->references('id')->on('nodes')->cascadeOnDelete(); + $table->foreign('role_id')->references('id')->on('roles')->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('node_role'); + } +}; diff --git a/lang/en/admin/role.php b/lang/en/admin/role.php index 1b99c0625..c4871ac7a 100644 --- a/lang/en/admin/role.php +++ b/lang/en/admin/role.php @@ -12,4 +12,6 @@ return [ 'root_admin' => 'The :role has all permissions.', 'root_admin_delete' => 'Can\'t delete Root Admin', 'users' => 'Users', + 'nodes' => 'Nodes', + 'nodes_hint' => 'Leave empty to allow access to all nodes.', ];