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:
Boy132 2025-05-05 12:58:55 +02:00 committed by GitHub
parent c0fda71e20
commit 03745eb4be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 219 additions and 12 deletions

View File

@ -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');
}
}

View File

@ -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'))),
]),
];
}

View File

@ -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');
}
}

View File

@ -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'));
}
}

View File

@ -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),
]);
}

View File

@ -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'));
});
}
}

View File

@ -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) {

View File

@ -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,

View File

@ -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
View 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;
}

View File

@ -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);
}
}

View File

@ -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

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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');
}
};

View File

@ -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.',
];