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..ed0553eb 100644 --- a/app/Livewire/DatabaseServer/Index.php +++ b/app/Livewire/DatabaseServer/Index.php @@ -112,22 +112,18 @@ public function confirmRestore(string $id): void { $server = DatabaseServer::findOrFail($id); - $this->authorize('restore', $server); - + $this->authorize('view', $server); $this->restoreId = $id; - if ($server->agent_id) { - $this->error(__('Restore is not yet supported for agent-backed servers.')); - - return; - } - if ($server->database_type === DatabaseType::REDIS) { $this->showRedisRestoreModal = true; return; } + // Policy blocks agent-backed servers and unauthorized users (403). + $this->authorize('restore', $server); + $this->dispatch('open-restore-modal', targetServerId: $id); } @@ -203,10 +199,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..c1707e79 100644 --- a/app/Livewire/DatabaseServer/RestoreModal.php +++ b/app/Livewire/DatabaseServer/RestoreModal.php @@ -96,6 +96,30 @@ 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::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($snapshot->id); + } + public function selectSnapshot(string $snapshotId): void { $this->selectedSnapshotId = $snapshotId; @@ -210,11 +234,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 +269,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..3623ee27 --- /dev/null +++ b/app/Livewire/User/ServerAccess.php @@ -0,0 +1,194 @@ + */ + 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(?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; + } + + 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 available for selection: excludes already-granted servers + * except the one currently selected (so editing a grant keeps it visible). + * + * @return array> + */ + 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', array_values($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..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; @@ -18,10 +19,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 +68,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 +76,32 @@ 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. + * 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 || $databaseServer->database_type === DatabaseType::REDIS) { + return false; + } + + 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..783ed821 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,25 @@ 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', 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); + } + }); + } + }); }); // Handle sorting diff --git a/app/Queries/DatabaseServerQuery.php b/app/Queries/DatabaseServerQuery.php index d490c0ad..ccd89515 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,15 +43,55 @@ 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']) - ->withCount('snapshots') - ->addSelect([ - 'restores_count' => Restore::selectRaw('count(*)') - ->whereColumn('target_server_id', 'database_servers.id'), - ]) + ->when( + $scopedUser !== null && $scopedUser->isScopedUser(), + function (Builder $query) use ($scopedUser) { + $query->whereIn('id', $scopedUser->getAccessibleServerIds()); + + // Build per-server correlated filters so each database_server row + // counts only snapshots/restores permitted by its specific grant. + $accesses = $scopedUser->serverAccesses()->get(); + + $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') + ->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/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..29f44d13 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/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) 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..e1f2d7ae --- /dev/null +++ b/resources/views/livewire/user/server-access.blade.php @@ -0,0 +1,174 @@ +
+ +
+
+

{{ __('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'); +});