Fix server access for admins without subuser (#919)

* fix server access for admins without subuser

* add permission checks to power buttons

* add permission check for console command sending

* fix tests

* fix websocket token permissions

* fix sftp access

* fix server api + small cleanup

* it's "update", not "edit"...

* fix tests

* fix permission const for "activity read"

* fix activity subuser permission
This commit is contained in:
Boy132 2025-01-17 23:04:22 +01:00 committed by GitHub
parent 61bdf0dcd7
commit 03eaddb126
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 103 additions and 45 deletions

View File

@ -19,7 +19,7 @@ class ListServers extends ListRecords
public function table(Table $table): Table public function table(Table $table): Table
{ {
$baseQuery = auth()->user()->can('viewList server') ? Server::query() : auth()->user()->accessibleServers(); $baseQuery = auth()->user()->accessibleServers();
return $table return $table
->paginated(false) ->paginated(false)

View File

@ -10,6 +10,7 @@ use App\Filament\Server\Widgets\ServerMemoryChart;
// use App\Filament\Server\Widgets\ServerNetworkChart; // use App\Filament\Server\Widgets\ServerNetworkChart;
use App\Filament\Server\Widgets\ServerOverview; use App\Filament\Server\Widgets\ServerOverview;
use App\Livewire\AlertBanner; use App\Livewire\AlertBanner;
use App\Models\Permission;
use App\Models\Server; use App\Models\Server;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -94,16 +95,19 @@ class Console extends Page
->color('primary') ->color('primary')
->size(ActionSize::ExtraLarge) ->size(ActionSize::ExtraLarge)
->action(fn () => $this->dispatch('setServerState', state: 'start', uuid: $server->uuid)) ->action(fn () => $this->dispatch('setServerState', state: 'start', uuid: $server->uuid))
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_START, $server))
->disabled(fn () => $server->isInConflictState() || !$this->status->isStartable()), ->disabled(fn () => $server->isInConflictState() || !$this->status->isStartable()),
Action::make('restart') Action::make('restart')
->color('gray') ->color('gray')
->size(ActionSize::ExtraLarge) ->size(ActionSize::ExtraLarge)
->action(fn () => $this->dispatch('setServerState', state: 'restart', uuid: $server->uuid)) ->action(fn () => $this->dispatch('setServerState', state: 'restart', uuid: $server->uuid))
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_RESTART, $server))
->disabled(fn () => $server->isInConflictState() || !$this->status->isRestartable()), ->disabled(fn () => $server->isInConflictState() || !$this->status->isRestartable()),
Action::make('stop') Action::make('stop')
->color('danger') ->color('danger')
->size(ActionSize::ExtraLarge) ->size(ActionSize::ExtraLarge)
->action(fn () => $this->dispatch('setServerState', state: 'stop', uuid: $server->uuid)) ->action(fn () => $this->dispatch('setServerState', state: 'stop', uuid: $server->uuid))
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->hidden(fn () => $this->status->isStartingOrStopping() || $this->status->isKillable()) ->hidden(fn () => $this->status->isStartingOrStopping() || $this->status->isKillable())
->disabled(fn () => $server->isInConflictState() || !$this->status->isStoppable()), ->disabled(fn () => $server->isInConflictState() || !$this->status->isStoppable()),
Action::make('kill') Action::make('kill')
@ -114,6 +118,7 @@ class Console extends Page
->modalSubmitActionLabel('Kill Server') ->modalSubmitActionLabel('Kill Server')
->size(ActionSize::ExtraLarge) ->size(ActionSize::ExtraLarge)
->action(fn () => $this->dispatch('setServerState', state: 'kill', uuid: $server->uuid)) ->action(fn () => $this->dispatch('setServerState', state: 'kill', uuid: $server->uuid))
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->hidden(fn () => $server->isInConflictState() || !$this->status->isKillable()), ->hidden(fn () => $server->isInConflictState() || !$this->status->isKillable()),
]; ];
} }

View File

@ -115,7 +115,9 @@ class ListUsers extends ListRecords
'settings' => [ 'settings' => [
'rename', 'rename',
'reinstall', 'reinstall',
'activity', ],
'activity' => [
'read',
], ],
]; ];
@ -357,6 +359,24 @@ class ListUsers extends ListRecords
]), ]),
]), ]),
]), ]),
Tabs\Tab::make('Activity')
->schema([
Section::make()
->description(trans('server/users.permissions.activity_desc'))
->icon('tabler-stack')
->schema([
CheckboxList::make('activity')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
])
->descriptions([
'read' => trans('server/users.permissions.activity_read'),
]),
]),
]),
]), ]),
]), ]),

View File

@ -67,9 +67,14 @@ class ServerConsole extends Widget
return $socket; return $socket;
} }
protected function authorizeSendCommand(): bool
{
return $this->user->can(Permission::ACTION_CONTROL_CONSOLE, $this->server);
}
protected function canSendCommand(): bool protected function canSendCommand(): bool
{ {
return !$this->server->isInConflictState() && $this->server->retrieveStatus() === 'running'; return $this->authorizeSendCommand() && !$this->server->isInConflictState() && $this->server->retrieveStatus() === 'running';
} }
public function up(): void public function up(): void

View File

@ -53,12 +53,12 @@ class ClientController extends ClientApiController
} else { } else {
$builder = $type === 'admin-all' $builder = $type === 'admin-all'
? $builder ? $builder
: $builder->whereNotIn('servers.id', $user->accessibleServers()->pluck('id')->all()); : $builder->whereNotIn('servers.id', $user->directAccessibleServers()->pluck('id')->all());
} }
} elseif ($type === 'owner') { } elseif ($type === 'owner') {
$builder = $builder->where('servers.owner_id', $user->id); $builder = $builder->where('servers.owner_id', $user->id);
} else { } else {
$builder = $builder->whereIn('servers.id', $user->accessibleServers()->pluck('id')->all()); $builder = $builder->whereIn('servers.id', $user->directAccessibleServers()->pluck('id')->all());
} }
$servers = $builder->paginate(min($request->query('per_page', '50'), 100))->appends($request->query()); $servers = $builder->paginate(min($request->query('per_page', '50'), 100))->appends($request->query());

View File

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

View File

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

View File

@ -55,8 +55,8 @@ abstract class SubuserRequest extends ClientApiRequest
/** @var \App\Models\Server $server */ /** @var \App\Models\Server $server */
$server = $this->route()->parameter('server'); $server = $this->route()->parameter('server');
// If we are a root admin or the server owner, no need to perform these checks. // If we are an admin or the server owner, no need to perform these checks.
if ($user->isRootAdmin() || $user->id === $server->owner_id) { if ($user->can('update server', $server) || $user->id === $server->owner_id) {
return; return;
} }

View File

@ -93,7 +93,7 @@ class Permission extends Model
public const ACTION_SETTINGS_REINSTALL = 'settings.reinstall'; public const ACTION_SETTINGS_REINSTALL = 'settings.reinstall';
public const ACTION_ACTIVITY_READ = 'settings.activity'; public const ACTION_ACTIVITY_READ = 'activity.read';
/** /**
* Should timestamps be used on this model. * Should timestamps be used on this model.

View File

@ -289,10 +289,23 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
} }
/** /**
* Returns all the servers that a user can access by way of being the owner of the * Returns all the servers that a user can access.
* server, or because they are assigned as a subuser for that server. * Either because they are an admin or because they are the owner/ a subuser of the server.
*/ */
public function accessibleServers(): Builder public function accessibleServers(): Builder
{
if ($this->canned('viewList server')) {
return Server::query();
}
return $this->directAccessibleServers();
}
/**
* Returns all the servers that a user can access "directly".
* This means either because they are the owner or a subuser of the server.
*/
public function directAccessibleServers(): Builder
{ {
return Server::query() return Server::query()
->select('servers.*') ->select('servers.*')
@ -315,7 +328,12 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
protected function checkPermission(Server $server, string $permission = ''): bool protected function checkPermission(Server $server, string $permission = ''): bool
{ {
if ($this->isRootAdmin() || $server->owner_id === $this->id) { if ($this->canned('update server', $server) || $server->owner_id === $this->id) {
return true;
}
// If the user only has "view" permissions allow viewing the console
if ($permission === Permission::ACTION_WEBSOCKET_CONNECT && $this->canned('view server', $server)) {
return true; return true;
} }
@ -361,6 +379,11 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
return $this->hasRole(Role::ROOT_ADMIN); return $this->hasRole(Role::ROOT_ADMIN);
} }
public function isAdmin(): bool
{
return $this->isRootAdmin() || ($this->roles()->count() >= 1 && $this->getAllPermissions()->count() >= 1);
}
public function canAccessPanel(Panel $panel): bool public function canAccessPanel(Panel $panel): bool
{ {
if ($this->isRootAdmin()) { if ($this->isRootAdmin()) {
@ -368,7 +391,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
} }
if ($panel->getId() === 'admin') { if ($panel->getId() === 'admin') {
return $this->roles()->count() >= 1 && $this->getAllPermissions()->count() >= 1; return $this->isAdmin();
} }
return true; return true;
@ -401,7 +424,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
public function canAccessTenant(IlluminateModel $tenant): bool public function canAccessTenant(IlluminateModel $tenant): bool
{ {
if ($tenant instanceof Server) { if ($tenant instanceof Server) {
if ($this->isRootAdmin() || $tenant->owner_id === $this->id) { if ($this->canned('view server', $tenant) || $tenant->owner_id === $this->id) {
return true; return true;
} }

View File

@ -9,23 +9,25 @@ class GetUserPermissionsService
{ {
/** /**
* Returns the server specific permissions that a user has. This checks * Returns the server specific permissions that a user has. This checks
* if they are an admin or a subuser for the server. If no permissions are * if they are an admin, the owner or a subuser for the server. If no
* found, an empty array is returned. * permissions are found, an empty array is returned.
*/ */
public function handle(Server $server, User $user): array public function handle(Server $server, User $user): array
{ {
if ($user->isRootAdmin() || $user->id === $server->owner_id) { if ($user->isAdmin() && ($user->can('view server', $server) || $user->can('update server', $server))) {
$permissions = ['*']; $permissions = $user->can('update server', $server) ? ['*'] : ['websocket.connect', 'backup.read'];
if ($user->isRootAdmin()) {
$permissions[] = 'admin.websocket.errors'; $permissions[] = 'admin.websocket.errors';
$permissions[] = 'admin.websocket.install'; $permissions[] = 'admin.websocket.install';
$permissions[] = 'admin.websocket.transfer'; $permissions[] = 'admin.websocket.transfer';
}
return $permissions; return $permissions;
} }
if ($user->id === $server->owner_id) {
return ['*'];
}
/** @var \App\Models\Subuser|null $subuserPermissions */ /** @var \App\Models\Subuser|null $subuserPermissions */
$subuserPermissions = $server->subusers()->where('user_id', $user->id)->first(); $subuserPermissions = $server->subusers()->where('user_id', $user->id)->first();

View File

@ -2,8 +2,9 @@
return [ return [
'permissions' => [ 'permissions' => [
'activity_desc' => 'Permissions that control a user\'s access to the server activity logs.',
'startup_desc' => 'Permissions that control a user\'s ability to view this server\'s startup parameters.', 'startup_desc' => 'Permissions that control a user\'s ability to view this server\'s startup parameters.',
'settings_desc' => 'Permissions that control a user\'s access to the schedule management for this server.', 'settings_desc' => 'Permissions that control a user\'s ability to modify this server\'s settings.',
'control_desc' => 'Permissions that control a user\'s ability to control the power state of a server, or send commands.', 'control_desc' => 'Permissions that control a user\'s ability to control the power state of a server, or send commands.',
'user_desc' => 'Permissions that allow a user to manage other subusers on a server. They will never be able to edit their own account, or assign permissions they do not have themselves.', 'user_desc' => 'Permissions that allow a user to manage other subusers on a server. They will never be able to edit their own account, or assign permissions they do not have themselves.',
'file_desc' => 'Permissions that control a user\'s ability to modify the filesystem for this server.', 'file_desc' => 'Permissions that control a user\'s ability to modify the filesystem for this server.',
@ -16,7 +17,7 @@ return [
'startup_docker_image' => 'Allows a user to modify the Docker image used when running the server.', 'startup_docker_image' => 'Allows a user to modify the Docker image used when running the server.',
'setting_reinstall' => 'Allows a user to trigger a reinstall of this server.', 'setting_reinstall' => 'Allows a user to trigger a reinstall of this server.',
'setting_rename' => 'Allows a user to rename this server and change the description of it.', 'setting_rename' => 'Allows a user to rename this server and change the description of it.',
'setting_activity' => 'Allows a user to view the activity logs for the server.', 'activity_read' => 'Allows a user to view the activity logs for the server.',
'websocket_*' => 'Allows a user access to the websocket for this server.', 'websocket_*' => 'Allows a user access to the websocket for this server.',
'control_console' => 'Allows a user to send data to the server console.', 'control_console' => 'Allows a user to send data to the server console.',
'control_start' => 'Allows a user to start the server instance.', 'control_start' => 'Allows a user to start the server instance.',

View File

@ -11,6 +11,7 @@
<div id="terminal" wire:ignore></div> <div id="terminal" wire:ignore></div>
@if ($this->authorizeSendCommand())
<div class="flex items-center w-full border-top overflow-hidden dark:bg-gray-900" <div class="flex items-center w-full border-top overflow-hidden dark:bg-gray-900"
style="border-bottom-right-radius: 10px; border-bottom-left-radius: 10px;"> style="border-bottom-right-radius: 10px; border-bottom-left-radius: 10px;">
<x-filament::icon <x-filament::icon
@ -28,6 +29,7 @@
wire:keydown.down="down" wire:keydown.down="down"
> >
</div> </div>
@endif
@script @script
<script> <script>