mirror of
https://github.com/pelican-dev/panel.git
synced 2025-05-19 22:14:45 +02:00
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>
This commit is contained in:
parent
c0fda71e20
commit
03745eb4be
@ -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');
|
||||
}
|
||||
}
|
||||
|
@ -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'))),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -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'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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.
|
||||
*/
|
||||
|
12
app/Models/NodeRole.php
Normal file
12
app/Models/NodeRole.php
Normal file
@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
class NodeRole extends Pivot
|
||||
{
|
||||
protected $table = 'node_role';
|
||||
|
||||
protected $primaryKey = null;
|
||||
}
|
@ -5,6 +5,7 @@ namespace App\Models;
|
||||
use App\Enums\RolePermissionModels;
|
||||
use App\Enums\RolePermissionPrefixes;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Spatie\Permission\Models\Role as BaseRole;
|
||||
|
||||
/**
|
||||
@ -15,6 +16,8 @@ use Spatie\Permission\Models\Role as BaseRole;
|
||||
* @property int|null $permissions_count
|
||||
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\User[] $users
|
||||
* @property int|null $users_count
|
||||
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\Node[] $nodes
|
||||
* @property int|null $nodes_count
|
||||
*/
|
||||
class Role extends BaseRole
|
||||
{
|
||||
@ -128,4 +131,9 @@ class Role extends BaseRole
|
||||
|
||||
return $role;
|
||||
}
|
||||
|
||||
public function nodes(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Node::class, NodeRole::class);
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
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
|
||||
{
|
||||
Schema::create('node_role', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
@ -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.',
|
||||
];
|
||||
|
Loading…
x
Reference in New Issue
Block a user