From b6f5560acdd1ff7896986f4b04c27bf38a8a055d Mon Sep 17 00:00:00 2001 From: Kay van Aarssen Date: Sat, 2 May 2026 10:20:22 +0200 Subject: [PATCH 1/5] feat: resource-scoped server access permissions for Viewer users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #257 Implements per-server and per-database access control for Viewer/Demo users, allowing admins to grant clients access to only the servers and databases they need — without promoting them to the Member role. New features: - user_server_accesses pivot table with per-server grants - Optional allowed_databases JSON column (null = all databases on server) - Per-grant action flags: can_download, can_backup, can_restore - Server Access admin UI embedded in the user edit page - Live database autocomplete from past snapshot history - Add/remove individual database restrictions - Scoped query filtering across server, snapshot, and job listings - Restore button on backup job rows (opens modal pre-selecting snapshot) - Worker container now runs as application user (fixes backup file permissions) Architecture: - Admin/Member roles are unaffected and always see everything - Viewer with no grants: unchanged behaviour (read-only, all servers) - Viewer with at least one grant: becomes a scoped user, visibility restricted to granted servers/databases across all index pages and policies - User::isScopedUser() and applyScopedSnapshotFilter() are reusable entry points for future scoping requirements --- app/Livewire/BackupJob/Index.php | 34 ++- app/Livewire/DatabaseServer/Index.php | 6 +- app/Livewire/DatabaseServer/RestoreModal.php | 25 +- app/Livewire/User/ServerAccess.php | 175 ++++++++++++ app/Models/DatabaseServer.php | 10 + app/Models/User.php | 63 +++++ app/Models/UserServerAccess.php | 79 ++++++ app/Policies/DatabaseServerPolicy.php | 22 +- app/Policies/SnapshotPolicy.php | 29 +- app/Queries/BackupJobQuery.php | 18 +- app/Queries/DatabaseServerQuery.php | 7 +- app/Queries/SnapshotQuery.php | 5 +- .../factories/UserServerAccessFactory.php | 48 ++++ ...0001_create_user_server_accesses_table.php | 29 ++ ...n_backup_to_user_server_accesses_table.php | 22 ++ docker-compose.yml | 1 + .../views/livewire/backup-job/index.blade.php | 13 + resources/views/livewire/user/edit.blade.php | 2 + .../livewire/user/server-access.blade.php | 166 +++++++++++ tests/Feature/User/ServerAccessTest.php | 264 ++++++++++++++++++ 20 files changed, 1007 insertions(+), 11 deletions(-) create mode 100644 app/Livewire/User/ServerAccess.php create mode 100644 app/Models/UserServerAccess.php create mode 100644 database/factories/UserServerAccessFactory.php create mode 100644 database/migrations/2026_05_02_000001_create_user_server_accesses_table.php create mode 100644 database/migrations/2026_05_02_000002_add_can_backup_to_user_server_accesses_table.php create mode 100644 resources/views/livewire/user/server-access.blade.php create mode 100644 tests/Feature/User/ServerAccessTest.php diff --git a/app/Livewire/BackupJob/Index.php b/app/Livewire/BackupJob/Index.php index 0cbff4c8..4e81a8be 100644 --- a/app/Livewire/BackupJob/Index.php +++ b/app/Livewire/BackupJob/Index.php @@ -2,6 +2,7 @@ namespace App\Livewire\BackupJob; +use App\Enums\DatabaseType; use App\Models\BackupJob; use App\Models\DatabaseServer; use App\Models\Snapshot; @@ -182,7 +183,13 @@ public function typeOptions(): array */ public function serverOptions(): array { + /** @var \App\Models\User $user */ + $user = auth()->user(); + return DatabaseServer::query() + ->when($user->isScopedUser(), function ($query) use ($user) { + $query->whereIn('id', $user->getAccessibleServerIds()); + }) ->orderBy('name') ->get() ->map(fn (DatabaseServer $server) => [ @@ -192,6 +199,27 @@ public function serverOptions(): array ->toArray(); } + public function confirmRestoreFromJob(string $serverId, string $snapshotId): void + { + $server = DatabaseServer::findOrFail($serverId); + + $this->authorize('restore', $server); + + if ($server->agent_id) { + $this->error(__('Restore is not yet supported for agent-backed servers.')); + + return; + } + + if ($server->database_type === DatabaseType::REDIS) { + $this->error(__('Automated restore is not supported for Redis/Valkey.')); + + return; + } + + $this->dispatch('open-restore-from-snapshot', targetServerId: $serverId, snapshotId: $snapshotId); + } + public function confirmDeleteSnapshot(string $snapshotId): void { $snapshot = Snapshot::findOrFail($snapshotId); @@ -259,6 +287,9 @@ public function deletePendingJob(): void public function render(): View { + /** @var \App\Models\User $user */ + $user = auth()->user(); + $jobs = BackupJobQuery::buildFromParams( search: $this->search, statusFilter: $this->statusFilter !== '' ? [$this->statusFilter] : [], @@ -266,7 +297,8 @@ public function render(): View serverFilter: $this->serverFilter, fileMissing: $this->fileMissing !== '', sortColumn: $this->sortBy['column'], - sortDirection: $this->sortBy['direction'] + sortDirection: $this->sortBy['direction'], + scopedUser: $user->isScopedUser() ? $user : null, )->paginate(15); return view('livewire.backup-job.index', [ diff --git a/app/Livewire/DatabaseServer/Index.php b/app/Livewire/DatabaseServer/Index.php index c56fca16..5534287b 100644 --- a/app/Livewire/DatabaseServer/Index.php +++ b/app/Livewire/DatabaseServer/Index.php @@ -203,10 +203,14 @@ public function runBackupAll(string $serverId, TriggerBackupAction $action): voi public function render(): View { + /** @var \App\Models\User $user */ + $user = auth()->user(); + $servers = DatabaseServerQuery::buildFromParams( search: $this->search, sortColumn: $this->sortBy['column'], - sortDirection: $this->sortBy['direction'] + sortDirection: $this->sortBy['direction'], + scopedUser: $user->isScopedUser() ? $user : null, )->paginate(10); // Share total count globally so it's available inside Mary UI scoped slots diff --git a/app/Livewire/DatabaseServer/RestoreModal.php b/app/Livewire/DatabaseServer/RestoreModal.php index 32447a9b..de948848 100644 --- a/app/Livewire/DatabaseServer/RestoreModal.php +++ b/app/Livewire/DatabaseServer/RestoreModal.php @@ -96,6 +96,19 @@ public function openModal(string $targetServerId): void $this->showModal = true; } + #[On('open-restore-from-snapshot')] + public function openFromSnapshot(string $targetServerId, string $snapshotId): void + { + $this->reset(['selectedSnapshotId', 'schemaName', 'forceDatabase', 'ownerUser', 'currentStep', 'existingDatabases', 'snapshotSearch', 'serverFilter']); + $this->resetPage('snapshots'); + $this->targetServer = DatabaseServer::find($targetServerId); + + $this->authorize('restore', $this->targetServer); + + $this->showModal = true; + $this->selectSnapshot($snapshotId); + } + public function selectSnapshot(string $snapshotId): void { $this->selectedSnapshotId = $snapshotId; @@ -210,11 +223,17 @@ public function getCompatibleServersProperty(): Collection return collect(); } + /** @var \App\Models\User $user */ + $user = auth()->user(); + return DatabaseServer::query() ->where('database_type', $this->targetServer->database_type) ->whereHas('snapshots', function ($query) { $query->whereHas('job', fn ($q) => $q->whereRaw("status = 'completed'")); }) + ->when($user->isScopedUser(), function ($query) use ($user) { + $query->whereIn('id', $user->getAccessibleServerIds()); + }) ->orderBy('name') ->get(['id', 'name']); } @@ -239,11 +258,15 @@ public function getPaginatedSnapshotsProperty(): ?LengthAwarePaginator return null; } + /** @var \App\Models\User $user */ + $user = auth()->user(); + return SnapshotQuery::buildFromParams( search: $this->snapshotSearch ?: null, statusFilter: 'completed', sortColumn: 'created_at', - sortDirection: 'desc' + sortDirection: 'desc', + scopedUser: $user->isScopedUser() ? $user : null, ) ->whereHas('databaseServer', fn (Builder $q) => $q->whereRaw('database_type = ?', [$this->targetServer->database_type])) ->when($this->serverFilter, fn ($q) => $q->where('database_server_id', $this->serverFilter)) diff --git a/app/Livewire/User/ServerAccess.php b/app/Livewire/User/ServerAccess.php new file mode 100644 index 00000000..1cfaabbb --- /dev/null +++ b/app/Livewire/User/ServerAccess.php @@ -0,0 +1,175 @@ + */ + public array $allowedDatabases = []; + + public string $databaseSearch = ''; + + public bool $canDownload = true; + + public bool $canBackup = false; + + public bool $canRestore = false; + + public function mount(User $user): void + { + $this->user = $user; + } + + public function openGrantModal(): void + { + $this->authorize('update', $this->user); + + $this->reset(['selectedServerId', 'allowedDatabases', 'databaseSearch', 'canDownload', 'canBackup', 'canRestore']); + $this->canDownload = true; + $this->showGrantModal = true; + } + + public function updatedSelectedServerId(): void + { + $this->databaseSearch = ''; + } + + /** + * Known database names from past snapshots for the selected server. + * + * @return array + */ + #[Computed] + public function knownDatabases(): array + { + if ($this->selectedServerId === '') { + return []; + } + + return Snapshot::where('database_server_id', $this->selectedServerId) + ->whereNotNull('database_name') + ->when($this->databaseSearch !== '', function ($q) { + $q->where('database_name', 'like', '%'.$this->databaseSearch.'%'); + }) + ->distinct() + ->orderBy('database_name') + ->pluck('database_name') + ->filter(fn ($db) => ! in_array($db, $this->allowedDatabases, strict: true)) + ->values() + ->all(); + } + + public function addDatabase(string $database): void + { + $db = trim($database); + + if ($db !== '' && ! in_array($db, $this->allowedDatabases, strict: true)) { + $this->allowedDatabases[] = $db; + } + + $this->databaseSearch = ''; + } + + public function addSearchedDatabase(): void + { + $this->addDatabase($this->databaseSearch); + } + + public function removeDatabase(string $database): void + { + $this->allowedDatabases = array_values( + array_filter($this->allowedDatabases, fn ($d) => $d !== $database) + ); + } + + public function grantAccess(): void + { + $this->authorize('update', $this->user); + + $this->validate([ + 'selectedServerId' => 'required|exists:database_servers,id', + 'canDownload' => 'boolean', + 'canBackup' => 'boolean', + 'canRestore' => 'boolean', + ]); + + UserServerAccess::updateOrCreate( + [ + 'user_id' => $this->user->id, + 'database_server_id' => $this->selectedServerId, + ], + [ + 'allowed_databases' => ! empty($this->allowedDatabases) ? array_values($this->allowedDatabases) : null, + 'can_download' => $this->canDownload, + 'can_backup' => $this->canBackup, + 'can_restore' => $this->canRestore, + ] + ); + + $this->showGrantModal = false; + $this->success(__('Access granted successfully.')); + } + + public function revokeAccess(int $accessId): void + { + $this->authorize('update', $this->user); + + UserServerAccess::where('id', $accessId) + ->where('user_id', $this->user->id) + ->delete(); + + $this->success(__('Access revoked successfully.')); + } + + /** + * Servers that can still be granted (exclude already-granted ones). + * + * @return array> + */ + public function availableServerOptions(): array + { + $grantedIds = $this->user->serverAccesses()->pluck('database_server_id')->all(); + + return DatabaseServer::query() + ->whereNotIn('id', $grantedIds) + ->orderBy('name') + ->get() + ->map(fn (DatabaseServer $server) => [ + 'id' => $server->id, + 'name' => $server->name.' ('.$server->database_type->value.')', + ]) + ->toArray(); + } + + public function render(): View + { + $accesses = $this->user->serverAccesses() + ->with('databaseServer') + ->get(); + + return view('livewire.user.server-access', [ + 'accesses' => $accesses, + 'availableServers' => $this->availableServerOptions(), + ]); + } +} diff --git a/app/Models/DatabaseServer.php b/app/Models/DatabaseServer.php index c3d87092..bdc66302 100644 --- a/app/Models/DatabaseServer.php +++ b/app/Models/DatabaseServer.php @@ -44,6 +44,8 @@ * @property-read int|null $snapshots_count * @property-read Collection $notificationChannels * @property-read int|null $notification_channels_count + * @property-read Collection $userAccesses + * @property-read int|null $user_accesses_count * * @method static DatabaseServerFactory factory($count = null, $state = []) * @method static Builder|DatabaseServer newModelQuery() @@ -180,6 +182,14 @@ public function sshConfig(): BelongsTo return $this->belongsTo(DatabaseServerSshConfig::class, 'ssh_config_id'); } + /** + * @return HasMany + */ + public function userAccesses(): HasMany + { + return $this->hasMany(UserServerAccess::class); + } + /** * Get the decrypted password with proper exception handling. * diff --git a/app/Models/User.php b/app/Models/User.php index 3098a8c1..ee4eb9a5 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -38,6 +38,8 @@ * @property-read int|null $triggered_snapshots_count * @property-read Collection $oauthIdentities * @property-read int|null $oauth_identities_count + * @property-read Collection $serverAccesses + * @property-read int|null $server_accesses_count * * @method static Builder|User active() * @method static UserFactory factory($count = null, $state = []) @@ -164,6 +166,14 @@ public function oauthIdentities(): HasMany return $this->hasMany(OAuthIdentity::class); } + /** + * @return HasMany + */ + public function serverAccesses(): HasMany + { + return $this->hasMany(UserServerAccess::class); + } + public function isDemo(): bool { return $this->role === self::ROLE_DEMO; @@ -194,6 +204,59 @@ public function canPerformActions(): bool return ! $this->isViewer() && ! $this->isDemo(); } + /** + * Returns true when this user's visibility is restricted by server access grants. + * Admins and members always see everything regardless of any grants. + */ + public function isScopedUser(): bool + { + if ($this->isAdmin() || $this->isMember()) { + return false; + } + + return $this->serverAccesses()->exists(); + } + + /** + * Returns the IDs of database servers this user is explicitly granted access to. + * + * @return array + */ + public function getAccessibleServerIds(): array + { + return $this->serverAccesses()->pluck('database_server_id')->all(); + } + + public function getServerAccess(DatabaseServer $server): ?UserServerAccess + { + return $this->serverAccesses() + ->where('database_server_id', $server->id) + ->first(); + } + + /** + * Applies per-server and per-database visibility constraints to a Snapshot query. + * Each grant contributes one OR branch: server match + optional database filter. + * + * @param Builder<\App\Models\Snapshot> $query + */ + public function applyScopedSnapshotFilter(Builder $query): void + { + $accesses = $this->serverAccesses()->get(); + + $query->where(function (Builder $q) use ($accesses) { + foreach ($accesses as $access) { + $q->orWhere(function (Builder $sq) use ($access) { + $sq->where('database_server_id', $access->database_server_id); + + if ($access->allowed_databases !== null) { + $sq->whereIn('database_name', $access->allowed_databases); + } + }); + } + }); + } + public function isPending(): bool { return $this->invitation_token !== null && $this->password === null; diff --git a/app/Models/UserServerAccess.php b/app/Models/UserServerAccess.php new file mode 100644 index 00000000..f10bde2f --- /dev/null +++ b/app/Models/UserServerAccess.php @@ -0,0 +1,79 @@ +|null $allowed_databases + * @property bool $can_download + * @property bool $can_backup + * @property bool $can_restore + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + * @property-read DatabaseServer $databaseServer + * @property-read User $user + * + * @method static UserServerAccessFactory factory($count = null, $state = []) + * + * @mixin \Eloquent + */ +class UserServerAccess extends Model +{ + /** @use HasFactory */ + use HasFactory; + + protected $fillable = [ + 'user_id', + 'database_server_id', + 'allowed_databases', + 'can_download', + 'can_backup', + 'can_restore', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'allowed_databases' => 'array', + 'can_download' => 'boolean', + 'can_backup' => 'boolean', + 'can_restore' => 'boolean', + ]; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function databaseServer(): BelongsTo + { + return $this->belongsTo(DatabaseServer::class); + } + + public function allowsDatabase(string $databaseName): bool + { + if ($this->allowed_databases === null) { + return true; + } + + return in_array($databaseName, $this->allowed_databases, strict: true); + } +} diff --git a/app/Policies/DatabaseServerPolicy.php b/app/Policies/DatabaseServerPolicy.php index 37f96dd9..7f1b504a 100644 --- a/app/Policies/DatabaseServerPolicy.php +++ b/app/Policies/DatabaseServerPolicy.php @@ -18,10 +18,14 @@ public function viewAny(User $user): bool /** * Determine whether the user can view the model. - * All authenticated users can view details. + * Scoped users may only see servers they have been granted access to. */ public function view(User $user, DatabaseServer $databaseServer): bool { + if ($user->isScopedUser()) { + return $user->getServerAccess($databaseServer) !== null; + } + return true; } @@ -63,7 +67,7 @@ public function delete(User $user, DatabaseServer $databaseServer): bool /** * Determine whether the user can run a backup. - * Demo users can trigger backups. + * Scoped users may trigger backups when their grant includes can_backup. */ public function backup(User $user, DatabaseServer $databaseServer): bool { @@ -71,15 +75,27 @@ public function backup(User $user, DatabaseServer $databaseServer): bool return false; } + if ($user->isScopedUser()) { + $access = $user->getServerAccess($databaseServer); + + return $access !== null && $access->can_backup; + } + return $user->isDemo() || $user->canPerformActions(); } /** * Determine whether the user can restore to a server. - * Demo users can trigger restores. + * Scoped users may restore only when their grant includes can_restore. */ public function restore(User $user, DatabaseServer $databaseServer): bool { + if ($user->isScopedUser()) { + $access = $user->getServerAccess($databaseServer); + + return $access !== null && $access->can_restore; + } + return $user->isDemo() || $user->canPerformActions(); } } diff --git a/app/Policies/SnapshotPolicy.php b/app/Policies/SnapshotPolicy.php index a93c69be..816d0b8b 100644 --- a/app/Policies/SnapshotPolicy.php +++ b/app/Policies/SnapshotPolicy.php @@ -18,10 +18,14 @@ public function viewAny(User $user): bool /** * Determine whether the user can view the model. - * All authenticated users can view details. + * Scoped users may only see snapshots from their granted servers and databases. */ public function view(User $user, Snapshot $snapshot): bool { + if ($user->isScopedUser()) { + return $this->snapshotIsAccessible($user, $snapshot); + } + return true; } @@ -36,10 +40,31 @@ public function delete(User $user, Snapshot $snapshot): bool /** * Determine whether the user can download the snapshot. - * Demo users can download snapshots. + * Scoped users may download when their grant includes can_download for that server/database. */ public function download(User $user, Snapshot $snapshot): bool { + if ($user->isScopedUser()) { + $access = $user->getServerAccess($snapshot->databaseServer); + + if ($access === null || ! $access->allowsDatabase($snapshot->database_name)) { + return false; + } + + return $access->can_download; + } + return $user->isDemo() || $user->canPerformActions(); } + + private function snapshotIsAccessible(User $user, Snapshot $snapshot): bool + { + $access = $user->getServerAccess($snapshot->databaseServer); + + if ($access === null) { + return false; + } + + return $access->allowsDatabase($snapshot->database_name); + } } diff --git a/app/Queries/BackupJobQuery.php b/app/Queries/BackupJobQuery.php index 3666587d..35a5b6b4 100644 --- a/app/Queries/BackupJobQuery.php +++ b/app/Queries/BackupJobQuery.php @@ -4,6 +4,7 @@ use App\Models\BackupJob; use App\Models\Snapshot; +use App\Models\User; use Illuminate\Database\Eloquent\Builder; use Spatie\QueryBuilder\AllowedFilter; use Spatie\QueryBuilder\AllowedSort; @@ -61,7 +62,8 @@ public static function buildFromParams( string $serverFilter = '', bool $fileMissing = false, string $sortColumn = 'created_at', - string $sortDirection = 'desc' + string $sortDirection = 'desc', + ?User $scopedUser = null, ): Builder { $query = BackupJob::query() ->with(self::RELATIONSHIPS) @@ -91,6 +93,20 @@ public static function buildFromParams( }) ->when($fileMissing, function (Builder $query) { $query->whereHas('snapshot', fn (Builder $q) => $q->whereRaw('file_exists = ?', [false])); + }) + ->when($scopedUser, function (Builder $query) use ($scopedUser) { + $accesses = $scopedUser->serverAccesses()->get(); + $query->where(function (Builder $q) use ($accesses) { + foreach ($accesses as $access) { + $q->orWhereHas('snapshot', function (Builder $sq) use ($access) { + $sq->whereRaw('database_server_id = ?', [$access->database_server_id]); + if ($access->allowed_databases !== null) { + $sq->whereIn('database_name', $access->allowed_databases); + } + }); + $q->orWhereHas('restore', fn (Builder $rq) => $rq->whereRaw('target_server_id = ?', [$access->database_server_id])); + } + }); }); // Handle sorting diff --git a/app/Queries/DatabaseServerQuery.php b/app/Queries/DatabaseServerQuery.php index d490c0ad..a163a98f 100644 --- a/app/Queries/DatabaseServerQuery.php +++ b/app/Queries/DatabaseServerQuery.php @@ -4,6 +4,7 @@ use App\Models\DatabaseServer; use App\Models\Restore; +use App\Models\User; use Illuminate\Database\Eloquent\Builder; use Spatie\QueryBuilder\AllowedFilter; use Spatie\QueryBuilder\AllowedSort; @@ -42,7 +43,8 @@ public static function make(): QueryBuilder public static function buildFromParams( ?string $search = null, string $sortColumn = 'created_at', - string $sortDirection = 'desc' + string $sortDirection = 'desc', + ?User $scopedUser = null, ): Builder { return DatabaseServer::query() ->with(['backups.volume', 'backups.backupSchedule', 'sshConfig', 'notificationChannels']) @@ -51,6 +53,9 @@ public static function buildFromParams( 'restores_count' => Restore::selectRaw('count(*)') ->whereColumn('target_server_id', 'database_servers.id'), ]) + ->when($scopedUser, function (Builder $query) use ($scopedUser) { + $query->whereIn('id', $scopedUser->getAccessibleServerIds()); + }) ->when($search, function (Builder $query) use ($search) { $query->where(function (Builder $q) use ($search) { $q->where('name', 'like', "%{$search}%") diff --git a/app/Queries/SnapshotQuery.php b/app/Queries/SnapshotQuery.php index 920423b2..d2af4d0c 100644 --- a/app/Queries/SnapshotQuery.php +++ b/app/Queries/SnapshotQuery.php @@ -3,6 +3,7 @@ namespace App\Queries; use App\Models\Snapshot; +use App\Models\User; use Illuminate\Database\Eloquent\Builder; use Spatie\QueryBuilder\AllowedFilter; use Spatie\QueryBuilder\AllowedSort; @@ -55,10 +56,12 @@ public static function buildFromParams( ?string $search = null, string $statusFilter = 'all', string $sortColumn = 'started_at', - string $sortDirection = 'desc' + string $sortDirection = 'desc', + ?User $scopedUser = null, ): Builder { return Snapshot::query() ->with(self::RELATIONSHIPS) + ->when($scopedUser, fn (Builder $q) => $scopedUser->applyScopedSnapshotFilter($q)) ->when($search, function (Builder $query) use ($search) { self::applySearch($query, $search); }) diff --git a/database/factories/UserServerAccessFactory.php b/database/factories/UserServerAccessFactory.php new file mode 100644 index 00000000..5c96ab9a --- /dev/null +++ b/database/factories/UserServerAccessFactory.php @@ -0,0 +1,48 @@ + + */ +class UserServerAccessFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'database_server_id' => DatabaseServer::factory(), + 'allowed_databases' => null, + 'can_download' => true, + 'can_backup' => false, + 'can_restore' => false, + ]; + } + + public function withDatabases(string ...$databases): static + { + return $this->state(['allowed_databases' => array_values($databases)]); + } + + public function canBackup(): static + { + return $this->state(['can_backup' => true]); + } + + public function canRestore(): static + { + return $this->state(['can_restore' => true]); + } + + public function cannotDownload(): static + { + return $this->state(['can_download' => false]); + } +} diff --git a/database/migrations/2026_05_02_000001_create_user_server_accesses_table.php b/database/migrations/2026_05_02_000001_create_user_server_accesses_table.php new file mode 100644 index 00000000..a9a86ce1 --- /dev/null +++ b/database/migrations/2026_05_02_000001_create_user_server_accesses_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('database_server_id', 26); + $table->foreign('database_server_id')->references('id')->on('database_servers')->cascadeOnDelete(); + $table->json('allowed_databases')->nullable()->comment('Null means all databases on this server'); + $table->boolean('can_download')->default(true); + $table->boolean('can_restore')->default(false); + $table->timestamps(); + + $table->unique(['user_id', 'database_server_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_server_accesses'); + } +}; diff --git a/database/migrations/2026_05_02_000002_add_can_backup_to_user_server_accesses_table.php b/database/migrations/2026_05_02_000002_add_can_backup_to_user_server_accesses_table.php new file mode 100644 index 00000000..8784f75c --- /dev/null +++ b/database/migrations/2026_05_02_000002_add_can_backup_to_user_server_accesses_table.php @@ -0,0 +1,22 @@ +boolean('can_backup')->default(false)->after('can_download'); + }); + } + + public function down(): void + { + Schema::table('user_server_accesses', function (Blueprint $table) { + $table->dropColumn('can_backup'); + }); + } +}; diff --git a/docker-compose.yml b/docker-compose.yml index 82abfd99..8c5f5a4a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,7 @@ services: - .:/app - app-data:/data command: sh -c "php artisan db:wait --check-migrations && php artisan queue:work --queue=backups,default --tries=3 --timeout=3600 --sleep=3 --max-jobs=1000" + user: "1000:1000" restart: unless-stopped environment: TZ: UTC diff --git a/resources/views/livewire/backup-job/index.blade.php b/resources/views/livewire/backup-job/index.blade.php index c739c33f..589f6b3a 100644 --- a/resources/views/livewire/backup-job/index.blade.php +++ b/resources/views/livewire/backup-job/index.blade.php @@ -141,6 +141,16 @@ class="btn-ghost btn-sm text-info" /> @endcan + @if($job->snapshot->databaseServer) + @can('restore', $job->snapshot->databaseServer) + + @endcan + @endif @endif + + + @include('livewire.backup-job._logs-modal') diff --git a/resources/views/livewire/user/edit.blade.php b/resources/views/livewire/user/edit.blade.php index c410860d..dcb99a52 100644 --- a/resources/views/livewire/user/edit.blade.php +++ b/resources/views/livewire/user/edit.blade.php @@ -72,4 +72,6 @@ + + diff --git a/resources/views/livewire/user/server-access.blade.php b/resources/views/livewire/user/server-access.blade.php new file mode 100644 index 00000000..2f2e1e7b --- /dev/null +++ b/resources/views/livewire/user/server-access.blade.php @@ -0,0 +1,166 @@ +
+ +
+
+

{{ __('Server Access') }}

+

+ @if($user->isAdmin() || $user->isMember()) + {{ __('This user has full access to all servers based on their role.') }} + @else + {{ __('Restrict this user to specific servers and databases. Without any grants, viewers see all servers.') }} + @endif +

+
+ @if(!$user->isAdmin() && !$user->isMember()) + @can('update', $user) + + @endcan + @endif +
+ + @if($user->isAdmin() || $user->isMember()) + {{-- No grants UI for privileged roles --}} + @elseif($accesses->isEmpty()) +
+ {{ __('No server access grants. This user can view all servers as a Viewer.') }} +
+ @else +
+ @foreach($accesses as $access) +
+
+
{{ $access->databaseServer->name }}
+
{{ $access->databaseServer->host }}
+ + @if($access->allowed_databases) +
+ @foreach($access->allowed_databases as $db) + + @endforeach +
+ @else +
{{ __('All databases') }}
+ @endif + +
+ @if($access->can_download) + + {{ __('Download') }} + + @endif + @if($access->can_backup) + + {{ __('Backup') }} + + @endif + @if($access->can_restore) + + {{ __('Restore') }} + + @endif +
+
+ + @can('update', $user) + + @endcan +
+ @endforeach +
+ @endif +
+ + + +
+ + +
+ +

{{ __('Leave empty to allow access to all databases on this server.') }}

+ + @if(count($allowedDatabases) > 0) +
+ @foreach($allowedDatabases as $db) + + {{ $db }} + + + @endforeach +
+ @endif + +
+ + +
+ + @if($selectedServerId !== '' && count($this->knownDatabases) > 0) +
+
+ {{ __('Known databases from past snapshots') }} +
+ @foreach($this->knownDatabases as $db) + + @endforeach +
+ @elseif($selectedServerId !== '' && $databaseSearch !== '' && count($this->knownDatabases) === 0) +

{{ __('No matching databases found in snapshot history. Press Add or Enter to add it anyway.') }}

+ @elseif($selectedServerId !== '') +

{{ __('Start typing to search databases from past snapshots.') }}

+ @endif +
+ +
+ + + + +
+
+ + + + + +
+
diff --git a/tests/Feature/User/ServerAccessTest.php b/tests/Feature/User/ServerAccessTest.php new file mode 100644 index 00000000..cfed93a3 --- /dev/null +++ b/tests/Feature/User/ServerAccessTest.php @@ -0,0 +1,264 @@ +create(['role' => 'viewer']); + $server = DatabaseServer::factory()->create(['name' => 'Production DB']); + + Livewire::actingAs($viewer) + ->test(DatabaseServerIndex::class) + ->assertSee('Production DB'); +}); + +test('viewer with grants only sees granted servers', function () { + $viewer = User::factory()->create(['role' => 'viewer']); + $granted = DatabaseServer::factory()->create(['name' => 'Client DB']); + $hidden = DatabaseServer::factory()->create(['name' => 'Other Client DB']); + + UserServerAccess::factory()->create([ + 'user_id' => $viewer->id, + 'database_server_id' => $granted->id, + ]); + + Livewire::actingAs($viewer) + ->test(DatabaseServerIndex::class) + ->assertSee('Client DB') + ->assertDontSee('Other Client DB'); +}); + +test('member sees all servers regardless of grants', function () { + $member = User::factory()->create(['role' => 'member']); + $server = DatabaseServer::factory()->create(['name' => 'All Servers DB']); + + // Grant to a different server only — member should still see both + UserServerAccess::factory()->create([ + 'user_id' => $member->id, + 'database_server_id' => DatabaseServer::factory()->create()->id, + ]); + + Livewire::actingAs($member) + ->test(DatabaseServerIndex::class) + ->assertSee('All Servers DB'); +}); + +test('admin sees all servers regardless of grants', function () { + $admin = User::factory()->create(['role' => 'admin']); + DatabaseServer::factory()->create(['name' => 'Admin Visible DB']); + + Livewire::actingAs($admin) + ->test(DatabaseServerIndex::class) + ->assertSee('Admin Visible DB'); +}); + +// ── Policy: DatabaseServerPolicy ──────────────────────────────────────────── + +test('scoped viewer cannot view server they have no grant for', function () { + $viewer = User::factory()->create(['role' => 'viewer']); + $server = DatabaseServer::factory()->create(); + + // Grant access to a different server so viewer becomes scoped + UserServerAccess::factory()->create([ + 'user_id' => $viewer->id, + 'database_server_id' => DatabaseServer::factory()->create()->id, + ]); + + expect($viewer->isScopedUser())->toBeTrue(); + expect($viewer->can('view', $server))->toBeFalse(); +}); + +test('scoped viewer can view server they have a grant for', function () { + $viewer = User::factory()->create(['role' => 'viewer']); + $server = DatabaseServer::factory()->create(); + + UserServerAccess::factory()->create([ + 'user_id' => $viewer->id, + 'database_server_id' => $server->id, + ]); + + expect($viewer->can('view', $server))->toBeTrue(); +}); + +test('scoped viewer without can_restore cannot restore', function () { + $viewer = User::factory()->create(['role' => 'viewer']); + $server = DatabaseServer::factory()->create(); + + UserServerAccess::factory()->create([ + 'user_id' => $viewer->id, + 'database_server_id' => $server->id, + 'can_restore' => false, + ]); + + expect($viewer->can('restore', $server))->toBeFalse(); +}); + +test('scoped viewer with can_restore can restore', function () { + $viewer = User::factory()->create(['role' => 'viewer']); + $server = DatabaseServer::factory()->create(); + + UserServerAccess::factory()->create([ + 'user_id' => $viewer->id, + 'database_server_id' => $server->id, + 'can_restore' => true, + ]); + + expect($viewer->can('restore', $server))->toBeTrue(); +}); + +// ── Policy: SnapshotPolicy ─────────────────────────────────────────────────── + +test('scoped viewer cannot download snapshot from ungranted server', function () { + $viewer = User::factory()->create(['role' => 'viewer']); + $server = DatabaseServer::factory()->create(); + + // Make viewer scoped by granting a different server + UserServerAccess::factory()->create([ + 'user_id' => $viewer->id, + 'database_server_id' => DatabaseServer::factory()->create()->id, + 'can_download' => true, + ]); + + $snapshot = \App\Models\Snapshot::factory()->forServer($server)->create(); + + expect($viewer->can('download', $snapshot))->toBeFalse(); +}); + +test('scoped viewer can download snapshot when grant allows all databases', function () { + $viewer = User::factory()->create(['role' => 'viewer']); + $server = DatabaseServer::factory()->create(); + + UserServerAccess::factory()->create([ + 'user_id' => $viewer->id, + 'database_server_id' => $server->id, + 'allowed_databases' => null, + 'can_download' => true, + ]); + + $snapshot = \App\Models\Snapshot::factory()->forServer($server)->create(['database_name' => 'any_db']); + + expect($viewer->can('download', $snapshot))->toBeTrue(); +}); + +test('scoped viewer cannot download snapshot for unallowed database', function () { + $viewer = User::factory()->create(['role' => 'viewer']); + $server = DatabaseServer::factory()->create(); + + UserServerAccess::factory()->create([ + 'user_id' => $viewer->id, + 'database_server_id' => $server->id, + 'allowed_databases' => ['allowed_db'], + 'can_download' => true, + ]); + + $snapshot = \App\Models\Snapshot::factory()->forServer($server)->create(['database_name' => 'other_db']); + + expect($viewer->can('download', $snapshot))->toBeFalse(); +}); + +test('scoped viewer can download snapshot for allowed database', function () { + $viewer = User::factory()->create(['role' => 'viewer']); + $server = DatabaseServer::factory()->create(); + + UserServerAccess::factory()->create([ + 'user_id' => $viewer->id, + 'database_server_id' => $server->id, + 'allowed_databases' => ['client_db'], + 'can_download' => true, + ]); + + $snapshot = \App\Models\Snapshot::factory()->forServer($server)->create(['database_name' => 'client_db']); + + expect($viewer->can('download', $snapshot))->toBeTrue(); +}); + +// ── Admin UI: ServerAccess component ──────────────────────────────────────── + +test('admin can grant server access to a viewer', function () { + $admin = User::factory()->create(['role' => 'admin']); + $viewer = User::factory()->create(['role' => 'viewer']); + $server = DatabaseServer::factory()->create(); + + Livewire::actingAs($admin) + ->test(ServerAccess::class, ['user' => $viewer]) + ->call('openGrantModal') + ->set('selectedServerId', $server->id) + ->set('canDownload', true) + ->set('canRestore', false) + ->call('grantAccess'); + + expect(UserServerAccess::where('user_id', $viewer->id) + ->where('database_server_id', $server->id) + ->exists() + )->toBeTrue(); +}); + +test('admin can revoke server access', function () { + $admin = User::factory()->create(['role' => 'admin']); + $viewer = User::factory()->create(['role' => 'viewer']); + $access = UserServerAccess::factory()->create(['user_id' => $viewer->id]); + + Livewire::actingAs($admin) + ->test(ServerAccess::class, ['user' => $viewer]) + ->call('revokeAccess', $access->id); + + expect(UserServerAccess::find($access->id))->toBeNull(); +}); + +test('non-admin cannot manage server access grants', function () { + $member = User::factory()->create(['role' => 'member']); + $viewer = User::factory()->create(['role' => 'viewer']); + + Livewire::actingAs($member) + ->test(ServerAccess::class, ['user' => $viewer]) + ->call('openGrantModal') + ->assertForbidden(); +}); + +test('granting access with specific databases restricts to those databases', function () { + $admin = User::factory()->create(['role' => 'admin']); + $viewer = User::factory()->create(['role' => 'viewer']); + $server = DatabaseServer::factory()->create(); + + Livewire::actingAs($admin) + ->test(ServerAccess::class, ['user' => $viewer]) + ->call('openGrantModal') + ->set('selectedServerId', $server->id) + ->set('allowedDatabases', ['client_db', 'shop_db']) + ->call('grantAccess'); + + $access = UserServerAccess::where('user_id', $viewer->id) + ->where('database_server_id', $server->id) + ->first(); + + expect($access)->not->toBeNull(); + expect($access->allowed_databases)->toBe(['client_db', 'shop_db']); +}); + +// ── BackupJob scoping ──────────────────────────────────────────────────────── + +test('scoped viewer only sees backup jobs for their granted servers', function () { + $viewer = User::factory()->create(['role' => 'viewer']); + $grantedServer = DatabaseServer::factory()->create(['name' => 'Granted Server']); + $otherServer = DatabaseServer::factory()->create(['name' => 'Hidden Server']); + + UserServerAccess::factory()->create([ + 'user_id' => $viewer->id, + 'database_server_id' => $grantedServer->id, + ]); + + \App\Models\Snapshot::factory()->forServer($grantedServer)->create(); + \App\Models\Snapshot::factory()->forServer($otherServer)->create(); + + Livewire::actingAs($viewer) + ->test(BackupJobIndex::class) + ->assertSee('Granted Server') + ->assertDontSee('Hidden Server'); +}); From e03f573bfed31c0acffaf65a3ec072ad99dabdd2 Mon Sep 17 00:00:00 2001 From: kayvanaarssen Date: Sat, 2 May 2026 10:56:05 +0200 Subject: [PATCH 2/5] fix: address review findings on resource-scoped permissions - Verify snapshot accessibility via scoped SnapshotQuery in RestoreModal::openFromSnapshot() before pre-selecting, preventing a scoped user from opening a snapshot they cannot access by supplying an arbitrary ULID through the Livewire event - Apply allowed_databases filter to restore jobs in BackupJobQuery so scoped users cannot see restore jobs for databases outside their grants - Scope snapshot and restore counts in DatabaseServerQuery to the union of allowed databases for scoped users, preventing count leakage on the server index - Support editing existing grants in ServerAccess: openGrantModal() now accepts an optional access ID, pre-fills the form, and keeps the server selectable by excluding it from the already-granted exclusion list; pencil edit button added alongside revoke - Use :tooltip binding (dynamic syntax) on the restore button in the jobs table to avoid HTML double-encoding --- app/Livewire/DatabaseServer/RestoreModal.php | 15 ++++++- app/Livewire/User/ServerAccess.php | 25 +++++++++-- app/Queries/BackupJobQuery.php | 7 ++- app/Queries/DatabaseServerQuery.php | 45 +++++++++++++++---- .../views/livewire/backup-job/index.blade.php | 2 +- .../livewire/user/server-access.blade.php | 22 ++++++--- 6 files changed, 94 insertions(+), 22 deletions(-) diff --git a/app/Livewire/DatabaseServer/RestoreModal.php b/app/Livewire/DatabaseServer/RestoreModal.php index de948848..c1707e79 100644 --- a/app/Livewire/DatabaseServer/RestoreModal.php +++ b/app/Livewire/DatabaseServer/RestoreModal.php @@ -101,12 +101,23 @@ public function openFromSnapshot(string $targetServerId, string $snapshotId): vo { $this->reset(['selectedSnapshotId', 'schemaName', 'forceDatabase', 'ownerUser', 'currentStep', 'existingDatabases', 'snapshotSearch', 'serverFilter']); $this->resetPage('snapshots'); - $this->targetServer = DatabaseServer::find($targetServerId); + $this->targetServer = DatabaseServer::findOrFail($targetServerId); $this->authorize('restore', $this->targetServer); + /** @var \App\Models\User $user */ + $user = auth()->user(); + + $snapshot = SnapshotQuery::buildFromParams( + statusFilter: 'completed', + scopedUser: $user->isScopedUser() ? $user : null, + ) + ->whereKey($snapshotId) + ->whereHas('databaseServer', fn (Builder $q) => $q->whereRaw('database_type = ?', [$this->targetServer->database_type->value])) + ->firstOrFail(); + $this->showModal = true; - $this->selectSnapshot($snapshotId); + $this->selectSnapshot($snapshot->id); } public function selectSnapshot(string $snapshotId): void diff --git a/app/Livewire/User/ServerAccess.php b/app/Livewire/User/ServerAccess.php index 1cfaabbb..3623ee27 100644 --- a/app/Livewire/User/ServerAccess.php +++ b/app/Livewire/User/ServerAccess.php @@ -40,12 +40,25 @@ public function mount(User $user): void $this->user = $user; } - public function openGrantModal(): void + public function openGrantModal(?string $accessId = null): void { $this->authorize('update', $this->user); $this->reset(['selectedServerId', 'allowedDatabases', 'databaseSearch', 'canDownload', 'canBackup', 'canRestore']); $this->canDownload = true; + + if ($accessId !== null) { + $access = UserServerAccess::where('id', $accessId) + ->where('user_id', $this->user->id) + ->firstOrFail(); + + $this->selectedServerId = $access->database_server_id; + $this->allowedDatabases = $access->allowed_databases ?? []; + $this->canDownload = $access->can_download; + $this->canBackup = $access->can_backup; + $this->canRestore = $access->can_restore; + } + $this->showGrantModal = true; } @@ -142,7 +155,8 @@ public function revokeAccess(int $accessId): void } /** - * Servers that can still be granted (exclude already-granted ones). + * Servers available for selection: excludes already-granted servers + * except the one currently selected (so editing a grant keeps it visible). * * @return array> */ @@ -150,8 +164,13 @@ public function availableServerOptions(): array { $grantedIds = $this->user->serverAccesses()->pluck('database_server_id')->all(); + // When editing an existing grant, keep its server selectable + if ($this->selectedServerId !== '') { + $grantedIds = array_filter($grantedIds, fn ($id) => $id !== $this->selectedServerId); + } + return DatabaseServer::query() - ->whereNotIn('id', $grantedIds) + ->whereNotIn('id', array_values($grantedIds)) ->orderBy('name') ->get() ->map(fn (DatabaseServer $server) => [ diff --git a/app/Queries/BackupJobQuery.php b/app/Queries/BackupJobQuery.php index 35a5b6b4..783ed821 100644 --- a/app/Queries/BackupJobQuery.php +++ b/app/Queries/BackupJobQuery.php @@ -104,7 +104,12 @@ public static function buildFromParams( $sq->whereIn('database_name', $access->allowed_databases); } }); - $q->orWhereHas('restore', fn (Builder $rq) => $rq->whereRaw('target_server_id = ?', [$access->database_server_id])); + $q->orWhereHas('restore', function (Builder $rq) use ($access) { + $rq->whereRaw('target_server_id = ?', [$access->database_server_id]); + if ($access->allowed_databases !== null) { + $rq->whereIn('schema_name', $access->allowed_databases); + } + }); } }); }); diff --git a/app/Queries/DatabaseServerQuery.php b/app/Queries/DatabaseServerQuery.php index a163a98f..83b20552 100644 --- a/app/Queries/DatabaseServerQuery.php +++ b/app/Queries/DatabaseServerQuery.php @@ -48,14 +48,43 @@ public static function buildFromParams( ): Builder { return DatabaseServer::query() ->with(['backups.volume', 'backups.backupSchedule', 'sshConfig', 'notificationChannels']) - ->withCount('snapshots') - ->addSelect([ - 'restores_count' => Restore::selectRaw('count(*)') - ->whereColumn('target_server_id', 'database_servers.id'), - ]) - ->when($scopedUser, function (Builder $query) use ($scopedUser) { - $query->whereIn('id', $scopedUser->getAccessibleServerIds()); - }) + ->when( + $scopedUser !== null, + function (Builder $query) use ($scopedUser) { + $query->whereIn('id', $scopedUser->getAccessibleServerIds()); + + // Collect all allowed databases across grants; null means unrestricted on that server + $allAllowedDbs = $scopedUser->serverAccesses()->get() + ->filter(fn ($a) => $a->allowed_databases !== null) + ->flatMap(fn ($a) => (array) $a->allowed_databases) + ->unique() + ->values() + ->all(); + + if (! empty($allAllowedDbs)) { + $query + ->withCount(['snapshots' => fn (Builder $q) => $q->whereIn('database_name', $allAllowedDbs)]) + ->addSelect([ + 'restores_count' => Restore::selectRaw('count(*)') + ->whereColumn('target_server_id', 'database_servers.id') + ->whereIn('schema_name', $allAllowedDbs), + ]); + } else { + $query + ->withCount('snapshots') + ->addSelect([ + 'restores_count' => Restore::selectRaw('count(*)') + ->whereColumn('target_server_id', 'database_servers.id'), + ]); + } + }, + fn (Builder $query) => $query + ->withCount('snapshots') + ->addSelect([ + 'restores_count' => Restore::selectRaw('count(*)') + ->whereColumn('target_server_id', 'database_servers.id'), + ]) + ) ->when($search, function (Builder $query) use ($search) { $query->where(function (Builder $q) use ($search) { $q->where('name', 'like', "%{$search}%") diff --git a/resources/views/livewire/backup-job/index.blade.php b/resources/views/livewire/backup-job/index.blade.php index 589f6b3a..29f44d13 100644 --- a/resources/views/livewire/backup-job/index.blade.php +++ b/resources/views/livewire/backup-job/index.blade.php @@ -146,7 +146,7 @@ class="btn-ghost btn-sm text-info" @endcan diff --git a/resources/views/livewire/user/server-access.blade.php b/resources/views/livewire/user/server-access.blade.php index 2f2e1e7b..5ec7d6ff 100644 --- a/resources/views/livewire/user/server-access.blade.php +++ b/resources/views/livewire/user/server-access.blade.php @@ -67,13 +67,21 @@ class="btn-primary btn-sm" @can('update', $user) - +
+ + +
@endcan @endforeach From 2fdbf85ba3ceecc4d9713484adce14959102f49a Mon Sep 17 00:00:00 2001 From: kayvanaarssen Date: Sat, 2 May 2026 11:13:43 +0200 Subject: [PATCH 3/5] fix: address remaining review findings - Block agent-backed server restores at the policy level; add Redis and agent guards to the API restore endpoint so automated restore cannot be triggered via API for unsupported server types (Redis manual-instructions UX in the UI is preserved since the policy now only blocks agents) - Gate the scoped-user query branch in DatabaseServerQuery on isScopedUser() so Admin/Member users passed as scopedUser never receive a restricted listing; replace the flat allowed-databases union with per-server correlated OR-WHERE conditions so snapshot and restore counts are filtered by each server's own grant rather than a merged global list - Convert {{ __('...') }} interpolation to :attr="__('...')" dynamic bindings throughout server-access.blade.php to avoid double-encoding of translated component attributes --- .../Api/V1/DatabaseServerController.php | 9 +++ app/Policies/DatabaseServerPolicy.php | 5 ++ app/Queries/DatabaseServerQuery.php | 55 +++++++++++-------- .../livewire/user/server-access.blade.php | 20 +++---- 4 files changed, 55 insertions(+), 34 deletions(-) diff --git a/app/Http/Controllers/Api/V1/DatabaseServerController.php b/app/Http/Controllers/Api/V1/DatabaseServerController.php index 7f686704..753b09d7 100644 --- a/app/Http/Controllers/Api/V1/DatabaseServerController.php +++ b/app/Http/Controllers/Api/V1/DatabaseServerController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\V1; +use App\Enums\DatabaseType; use App\Http\Controllers\Controller; use App\Http\Requests\Api\V1\RestoreRequest; use App\Http\Requests\Api\V1\SaveDatabaseServerRequest; @@ -195,6 +196,14 @@ public function restore( ): JsonResponse { $this->authorize('restore', $databaseServer); + if ($databaseServer->agent_id !== null) { + return response()->json(['message' => 'Restore is not supported for agent-backed servers.'], 422); + } + + if ($databaseServer->database_type === DatabaseType::REDIS) { + return response()->json(['message' => 'Automated restore is not supported for Redis/Valkey servers.'], 422); + } + /** @var Snapshot $snapshot */ $snapshot = Snapshot::findOrFail($request->validated('snapshot_id')); diff --git a/app/Policies/DatabaseServerPolicy.php b/app/Policies/DatabaseServerPolicy.php index 7f1b504a..67d86f72 100644 --- a/app/Policies/DatabaseServerPolicy.php +++ b/app/Policies/DatabaseServerPolicy.php @@ -86,10 +86,15 @@ public function backup(User $user, DatabaseServer $databaseServer): bool /** * Determine whether the user can restore to a server. + * Agent-backed servers do not support restore in any form. * Scoped users may restore only when their grant includes can_restore. */ public function restore(User $user, DatabaseServer $databaseServer): bool { + if ($databaseServer->agent_id !== null) { + return false; + } + if ($user->isScopedUser()) { $access = $user->getServerAccess($databaseServer); diff --git a/app/Queries/DatabaseServerQuery.php b/app/Queries/DatabaseServerQuery.php index 83b20552..ccd89515 100644 --- a/app/Queries/DatabaseServerQuery.php +++ b/app/Queries/DatabaseServerQuery.php @@ -49,34 +49,41 @@ public static function buildFromParams( return DatabaseServer::query() ->with(['backups.volume', 'backups.backupSchedule', 'sshConfig', 'notificationChannels']) ->when( - $scopedUser !== null, + $scopedUser !== null && $scopedUser->isScopedUser(), function (Builder $query) use ($scopedUser) { $query->whereIn('id', $scopedUser->getAccessibleServerIds()); - // Collect all allowed databases across grants; null means unrestricted on that server - $allAllowedDbs = $scopedUser->serverAccesses()->get() - ->filter(fn ($a) => $a->allowed_databases !== null) - ->flatMap(fn ($a) => (array) $a->allowed_databases) - ->unique() - ->values() - ->all(); + // Build per-server correlated filters so each database_server row + // counts only snapshots/restores permitted by its specific grant. + $accesses = $scopedUser->serverAccesses()->get(); - if (! empty($allAllowedDbs)) { - $query - ->withCount(['snapshots' => fn (Builder $q) => $q->whereIn('database_name', $allAllowedDbs)]) - ->addSelect([ - 'restores_count' => Restore::selectRaw('count(*)') - ->whereColumn('target_server_id', 'database_servers.id') - ->whereIn('schema_name', $allAllowedDbs), - ]); - } else { - $query - ->withCount('snapshots') - ->addSelect([ - 'restores_count' => Restore::selectRaw('count(*)') - ->whereColumn('target_server_id', 'database_servers.id'), - ]); - } + $query + ->withCount(['snapshots' => function (Builder $q) use ($accesses) { + $q->where(function (Builder $inner) use ($accesses) { + foreach ($accesses as $access) { + $inner->orWhere(function (Builder $s) use ($access) { + $s->whereRaw('snapshots.database_server_id = ?', [$access->database_server_id]); + if ($access->allowed_databases !== null) { + $s->whereIn('database_name', $access->allowed_databases); + } + }); + } + }); + }]) + ->addSelect([ + 'restores_count' => Restore::selectRaw('count(*)') + ->whereColumn('target_server_id', 'database_servers.id') + ->where(function (Builder $q) use ($accesses) { + foreach ($accesses as $access) { + $q->orWhere(function (Builder $s) use ($access) { + $s->whereRaw('target_server_id = ?', [$access->database_server_id]); + if ($access->allowed_databases !== null) { + $s->whereIn('schema_name', $access->allowed_databases); + } + }); + } + }), + ]); }, fn (Builder $query) => $query ->withCount('snapshots') diff --git a/resources/views/livewire/user/server-access.blade.php b/resources/views/livewire/user/server-access.blade.php index 5ec7d6ff..e1f2d7ae 100644 --- a/resources/views/livewire/user/server-access.blade.php +++ b/resources/views/livewire/user/server-access.blade.php @@ -14,7 +14,7 @@ @if(!$user->isAdmin() && !$user->isMember()) @can('update', $user) @@ -122,13 +122,13 @@ class="ml-0.5 hover:text-error leading-none"
- - - + + +
- - + + From ed1f0602c79d44efbf1b30afdf561010fcab2f22 Mon Sep 17 00:00:00 2001 From: kayvanaarssen Date: Sat, 2 May 2026 11:23:14 +0200 Subject: [PATCH 4/5] fix: centralise restore eligibility in policy Move the Redis/Valkey automated-restore restriction from the API controller into DatabaseServerPolicy::restore() alongside the existing agent check, so authorization is the single source of truth for both the UI and the API. The Redis manual-instructions flow is preserved by checking for Redis before the authorize() call in Index::confirmRestore(), since showing documentation does not dispatch a restore job. The blade button is similarly split so Redis servers show a "View Restore Instructions" button outside the @can('restore') gate, while all other servers remain policy-gated as before. The now-redundant 422 guards in the API controller are removed. --- .../Api/V1/DatabaseServerController.php | 9 --------- app/Livewire/DatabaseServer/Index.php | 17 +++++++---------- app/Policies/DatabaseServerPolicy.php | 5 +++-- .../livewire/database-server/index.blade.php | 11 ++++++++--- 4 files changed, 18 insertions(+), 24 deletions(-) diff --git a/app/Http/Controllers/Api/V1/DatabaseServerController.php b/app/Http/Controllers/Api/V1/DatabaseServerController.php index 753b09d7..7f686704 100644 --- a/app/Http/Controllers/Api/V1/DatabaseServerController.php +++ b/app/Http/Controllers/Api/V1/DatabaseServerController.php @@ -2,7 +2,6 @@ namespace App\Http\Controllers\Api\V1; -use App\Enums\DatabaseType; use App\Http\Controllers\Controller; use App\Http\Requests\Api\V1\RestoreRequest; use App\Http\Requests\Api\V1\SaveDatabaseServerRequest; @@ -196,14 +195,6 @@ public function restore( ): JsonResponse { $this->authorize('restore', $databaseServer); - if ($databaseServer->agent_id !== null) { - return response()->json(['message' => 'Restore is not supported for agent-backed servers.'], 422); - } - - if ($databaseServer->database_type === DatabaseType::REDIS) { - return response()->json(['message' => 'Automated restore is not supported for Redis/Valkey servers.'], 422); - } - /** @var Snapshot $snapshot */ $snapshot = Snapshot::findOrFail($request->validated('snapshot_id')); diff --git a/app/Livewire/DatabaseServer/Index.php b/app/Livewire/DatabaseServer/Index.php index 5534287b..d4141949 100644 --- a/app/Livewire/DatabaseServer/Index.php +++ b/app/Livewire/DatabaseServer/Index.php @@ -112,22 +112,19 @@ public function confirmRestore(string $id): void { $server = DatabaseServer::findOrFail($id); - $this->authorize('restore', $server); - - $this->restoreId = $id; - - if ($server->agent_id) { - $this->error(__('Restore is not yet supported for agent-backed servers.')); - - return; - } - + // Redis/Valkey: show manual instructions without a policy check since + // no automated restore job is dispatched — just documentation. if ($server->database_type === DatabaseType::REDIS) { $this->showRedisRestoreModal = true; return; } + // Policy blocks agent-backed servers and unathorized users (403). + $this->authorize('restore', $server); + + $this->restoreId = $id; + $this->dispatch('open-restore-modal', targetServerId: $id); } diff --git a/app/Policies/DatabaseServerPolicy.php b/app/Policies/DatabaseServerPolicy.php index 67d86f72..fc8a6660 100644 --- a/app/Policies/DatabaseServerPolicy.php +++ b/app/Policies/DatabaseServerPolicy.php @@ -2,6 +2,7 @@ namespace App\Policies; +use App\Enums\DatabaseType; use App\Models\DatabaseServer; use App\Models\User; @@ -86,12 +87,12 @@ public function backup(User $user, DatabaseServer $databaseServer): bool /** * Determine whether the user can restore to a server. - * Agent-backed servers do not support restore in any form. + * Agent-backed and Redis/Valkey servers do not support automated restore. * Scoped users may restore only when their grant includes can_restore. */ public function restore(User $user, DatabaseServer $databaseServer): bool { - if ($databaseServer->agent_id !== null) { + if ($databaseServer->agent_id !== null || $databaseServer->database_type === DatabaseType::REDIS) { return false; } diff --git a/resources/views/livewire/database-server/index.blade.php b/resources/views/livewire/database-server/index.blade.php index 329f7ac8..aa053844 100644 --- a/resources/views/livewire/database-server/index.blade.php +++ b/resources/views/livewire/database-server/index.blade.php @@ -125,10 +125,15 @@ class="flex items-center gap-1 hover:text-success transition-colors tooltip @if @endcan - @can('restore', $server) + @if($server->database_type === \App\Enums\DatabaseType::REDIS) - @endcan + :tooltip="__('View Restore Instructions')" class="btn-ghost btn-sm text-success" /> + @else + @can('restore', $server) + + @endcan + @endif
@can('viewForm', $server) From 8876b9b925b8b0fd75691d5ad16d8fc60f835be4 Mon Sep 17 00:00:00 2001 From: kayvanaarssen Date: Sat, 2 May 2026 11:30:15 +0200 Subject: [PATCH 5/5] fix: authorize view and set restoreId before Redis branch in confirmRestore Add authorize('view', \$server) before the Redis early-return so any logged-in user cannot invoke confirmRestore with an arbitrary Redis server ID without passing an authorization check. Move \$this->restoreId = \$id to the same position so the modal's backup-jobs link always reflects the correct server, even when the Redis instructions path is taken. --- app/Livewire/DatabaseServer/Index.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/Livewire/DatabaseServer/Index.php b/app/Livewire/DatabaseServer/Index.php index d4141949..ed0553eb 100644 --- a/app/Livewire/DatabaseServer/Index.php +++ b/app/Livewire/DatabaseServer/Index.php @@ -112,19 +112,18 @@ public function confirmRestore(string $id): void { $server = DatabaseServer::findOrFail($id); - // Redis/Valkey: show manual instructions without a policy check since - // no automated restore job is dispatched — just documentation. + $this->authorize('view', $server); + $this->restoreId = $id; + if ($server->database_type === DatabaseType::REDIS) { $this->showRedisRestoreModal = true; return; } - // Policy blocks agent-backed servers and unathorized users (403). + // Policy blocks agent-backed servers and unauthorized users (403). $this->authorize('restore', $server); - $this->restoreId = $id; - $this->dispatch('open-restore-modal', targetServerId: $id); }