From beaad0a722a8f944c8269422940b6c67c9cf2ab1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 13:38:28 +0200 Subject: [PATCH] Refine service resource routing --- app/Livewire/Project/Database/Import.php | 55 ++++--- .../Project/Service/DatabaseBackups.php | 14 +- app/Livewire/Project/Service/Heading.php | 30 ++++ app/Livewire/Project/Service/Index.php | 14 +- tests/Feature/ServiceResourceRoutingTest.php | 141 ++++++++++++++++++ 5 files changed, 226 insertions(+), 28 deletions(-) create mode 100644 tests/Feature/ServiceResourceRoutingTest.php diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index 1cdc681cd..0fddce274 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -5,6 +5,15 @@ use App\Models\S3Storage; use App\Models\Server; use App\Models\Service; +use App\Models\ServiceDatabase; +use App\Models\StandaloneClickhouse; +use App\Models\StandaloneDragonfly; +use App\Models\StandaloneKeydb; +use App\Models\StandaloneMariadb; +use App\Models\StandaloneMongodb; +use App\Models\StandaloneMysql; +use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; @@ -219,7 +228,7 @@ public function updatedDumpAll($value) $morphClass = $this->resource->getMorphClass(); // Handle ServiceDatabase by checking the database type - if ($morphClass === \App\Models\ServiceDatabase::class) { + if ($morphClass === ServiceDatabase::class) { $dbType = $this->resource->databaseType(); if (str_contains($dbType, 'mysql')) { $morphClass = 'mysql'; @@ -231,7 +240,7 @@ public function updatedDumpAll($value) } switch ($morphClass) { - case \App\Models\StandaloneMariadb::class: + case StandaloneMariadb::class: case 'mariadb': if ($value === true) { $this->mariadbRestoreCommand = <<<'EOD' @@ -247,7 +256,7 @@ public function updatedDumpAll($value) $this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'; } break; - case \App\Models\StandaloneMysql::class: + case StandaloneMysql::class: case 'mysql': if ($value === true) { $this->mysqlRestoreCommand = <<<'EOD' @@ -263,7 +272,7 @@ public function updatedDumpAll($value) $this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; } break; - case \App\Models\StandalonePostgresql::class: + case StandalonePostgresql::class: case 'postgresql': if ($value === true) { $this->postgresqlRestoreCommand = <<<'EOD' @@ -299,10 +308,16 @@ public function getContainers() } elseif ($stackServiceUuid) { // ServiceDatabase route - look up the service database $serviceUuid = data_get($this->parameters, 'service_uuid'); - $service = Service::whereUuid($serviceUuid)->first(); - if (! $service) { - abort(404); - } + $project = currentTeam() + ->projects() + ->select('id', 'uuid', 'team_id') + ->where('uuid', data_get($this->parameters, 'project_uuid')) + ->firstOrFail(); + $environment = $project->environments() + ->select('id', 'uuid', 'name', 'project_id') + ->where('uuid', data_get($this->parameters, 'environment_uuid')) + ->firstOrFail(); + $service = $environment->services()->whereUuid($serviceUuid)->firstOrFail(); $resource = $service->databases()->whereUuid($stackServiceUuid)->first(); if (is_null($resource)) { abort(404); @@ -321,7 +336,7 @@ public function getContainers() $this->resourceStatus = $resource->status ?? ''; // Handle ServiceDatabase server access differently - if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($resource->getMorphClass() === ServiceDatabase::class) { $server = $resource->service?->server; if (! $server) { abort(404, 'Server not found for this service database.'); @@ -359,16 +374,16 @@ public function getContainers() } if ( - $resource->getMorphClass() === \App\Models\StandaloneRedis::class || - $resource->getMorphClass() === \App\Models\StandaloneKeydb::class || - $resource->getMorphClass() === \App\Models\StandaloneDragonfly::class || - $resource->getMorphClass() === \App\Models\StandaloneClickhouse::class + $resource->getMorphClass() === StandaloneRedis::class || + $resource->getMorphClass() === StandaloneKeydb::class || + $resource->getMorphClass() === StandaloneDragonfly::class || + $resource->getMorphClass() === StandaloneClickhouse::class ) { $this->unsupported = true; } // Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.) - if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($resource->getMorphClass() === ServiceDatabase::class) { $dbType = $resource->databaseType(); if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') || str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) { @@ -664,7 +679,7 @@ public function restoreFromS3(string $password = ''): bool|string $fullImageName = "{$helperImage}:{$latestVersion}"; // Get the database destination network - if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($this->resource->getMorphClass() === ServiceDatabase::class) { $destinationNetwork = $this->resource->service->destination->network ?? 'coolify'; } else { $destinationNetwork = $this->resource->destination->network ?? 'coolify'; @@ -756,7 +771,7 @@ public function buildRestoreCommand(string $tmpPath): string $morphClass = $this->resource->getMorphClass(); // Handle ServiceDatabase by checking the database type - if ($morphClass === \App\Models\ServiceDatabase::class) { + if ($morphClass === ServiceDatabase::class) { $dbType = $this->resource->databaseType(); if (str_contains($dbType, 'mysql')) { $morphClass = 'mysql'; @@ -770,7 +785,7 @@ public function buildRestoreCommand(string $tmpPath): string } switch ($morphClass) { - case \App\Models\StandaloneMariadb::class: + case StandaloneMariadb::class: case 'mariadb': $restoreCommand = $this->mariadbRestoreCommand; if ($this->dumpAll) { @@ -779,7 +794,7 @@ public function buildRestoreCommand(string $tmpPath): string $restoreCommand .= " < {$tmpPath}"; } break; - case \App\Models\StandaloneMysql::class: + case StandaloneMysql::class: case 'mysql': $restoreCommand = $this->mysqlRestoreCommand; if ($this->dumpAll) { @@ -788,7 +803,7 @@ public function buildRestoreCommand(string $tmpPath): string $restoreCommand .= " < {$tmpPath}"; } break; - case \App\Models\StandalonePostgresql::class: + case StandalonePostgresql::class: case 'postgresql': $restoreCommand = $this->postgresqlRestoreCommand; if ($this->dumpAll) { @@ -797,7 +812,7 @@ public function buildRestoreCommand(string $tmpPath): string $restoreCommand .= " {$tmpPath}"; } break; - case \App\Models\StandaloneMongodb::class: + case StandaloneMongodb::class: case 'mongodb': $restoreCommand = $this->mongodbRestoreCommand; if ($this->dumpAll === false) { diff --git a/app/Livewire/Project/Service/DatabaseBackups.php b/app/Livewire/Project/Service/DatabaseBackups.php index 826a6c1ff..883441ecb 100644 --- a/app/Livewire/Project/Service/DatabaseBackups.php +++ b/app/Livewire/Project/Service/DatabaseBackups.php @@ -28,10 +28,16 @@ public function mount() try { $this->parameters = get_route_parameters(); $this->query = request()->query(); - $this->service = Service::whereUuid($this->parameters['service_uuid'])->first(); - if (! $this->service) { - return redirect()->route('dashboard'); - } + $project = currentTeam() + ->projects() + ->select('id', 'uuid', 'team_id') + ->where('uuid', $this->parameters['project_uuid']) + ->firstOrFail(); + $environment = $project->environments() + ->select('id', 'uuid', 'name', 'project_id') + ->where('uuid', $this->parameters['environment_uuid']) + ->firstOrFail(); + $this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail(); $this->authorize('view', $this->service); $this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first(); diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php index c8a08d8f9..60273ab23 100644 --- a/app/Livewire/Project/Service/Heading.php +++ b/app/Livewire/Project/Service/Heading.php @@ -7,12 +7,15 @@ use App\Actions\Service\StopService; use App\Enums\ProcessStatus; use App\Models\Service; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Livewire\Component; use Spatie\Activitylog\Models\Activity; class Heading extends Component { + use AuthorizesRequests; + public Service $service; public array $parameters; @@ -27,6 +30,8 @@ class Heading extends Component public function mount() { + $this->authorizeService('view'); + if (str($this->service->status)->contains('running') && is_null($this->service->config_hash)) { $this->service->isConfigurationChanged(true); $this->dispatch('configurationChanged'); @@ -47,6 +52,8 @@ public function getListeners() public function checkStatus() { + $this->authorizeService('view'); + if ($this->service->server->isFunctional()) { GetContainersStatus::dispatch($this->service->server); } else { @@ -61,6 +68,8 @@ public function manualCheckStatus() public function serviceChecked() { + $this->authorizeService('view'); + try { $this->service->applications->each(function ($application) { $application->refresh(); @@ -82,6 +91,8 @@ public function serviceChecked() public function checkDeployments() { + $this->authorizeService('view'); + try { $activity = Activity::where('properties->type_uuid', $this->service->uuid)->latest()->first(); $status = data_get($activity, 'properties.status'); @@ -99,12 +110,16 @@ public function checkDeployments() public function start() { + $this->authorizeService('deploy'); + $activity = StartService::run($this->service, pullLatestImages: true); $this->dispatch('activityMonitor', $activity->id); } public function forceDeploy() { + $this->authorizeService('deploy'); + try { $activities = Activity::where('properties->type_uuid', $this->service->uuid) ->where(function ($q) { @@ -124,6 +139,8 @@ public function forceDeploy() public function stop() { + $this->authorizeService('stop'); + try { StopService::dispatch($this->service, false, $this->docker_cleanup); } catch (\Exception $e) { @@ -133,6 +150,8 @@ public function stop() public function restart() { + $this->authorizeService('deploy'); + $this->checkDeployments(); if ($this->isDeploymentProgress) { $this->dispatch('error', 'There is a deployment in progress.'); @@ -145,6 +164,8 @@ public function restart() public function pullAndRestartEvent() { + $this->authorizeService('deploy'); + $this->checkDeployments(); if ($this->isDeploymentProgress) { $this->dispatch('error', 'There is a deployment in progress.'); @@ -155,6 +176,15 @@ public function pullAndRestartEvent() $this->dispatch('activityMonitor', $activity->id); } + private function authorizeService(string $ability): void + { + $this->service = Service::ownedByCurrentTeam() + ->whereKey($this->service->getKey()) + ->firstOrFail(); + + $this->authorize($ability, $this->service); + } + public function render() { return view('livewire.project.service.heading', [ diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index cb2d977bc..12c0edbca 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -108,10 +108,16 @@ public function mount() $this->parameters = get_route_parameters(); $this->query = request()->query(); $this->currentRoute = request()->route()->getName(); - $this->service = Service::whereUuid($this->parameters['service_uuid'])->first(); - if (! $this->service) { - return redirect()->route('dashboard'); - } + $project = currentTeam() + ->projects() + ->select('id', 'uuid', 'team_id') + ->where('uuid', $this->parameters['project_uuid']) + ->firstOrFail(); + $environment = $project->environments() + ->select('id', 'uuid', 'name', 'project_id') + ->where('uuid', $this->parameters['environment_uuid']) + ->firstOrFail(); + $this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail(); $this->authorize('view', $this->service); $service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first(); if ($service) { diff --git a/tests/Feature/ServiceResourceRoutingTest.php b/tests/Feature/ServiceResourceRoutingTest.php new file mode 100644 index 000000000..f27340330 --- /dev/null +++ b/tests/Feature/ServiceResourceRoutingTest.php @@ -0,0 +1,141 @@ +id = 0; + $settings->save(); + Once::flush(); + + $this->userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + + $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->destinationA = StandaloneDocker::factory()->create([ + 'server_id' => $this->serverA->id, + 'network' => 'team-a-network', + ]); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + + $this->userB = User::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->userB->teams()->attach($this->teamB, ['role' => 'owner']); + + $this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]); + $this->destinationB = StandaloneDocker::factory()->create([ + 'server_id' => $this->serverB->id, + 'network' => 'team-b-network', + ]); + $this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]); + $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]); + + $this->otherService = Service::factory()->create([ + 'server_id' => $this->serverB->id, + 'destination_id' => $this->destinationB->id, + 'destination_type' => $this->destinationB->getMorphClass(), + 'environment_id' => $this->environmentB->id, + ]); + $this->otherServiceApplication = ServiceApplication::create([ + 'service_id' => $this->otherService->id, + 'name' => 'other-app', + 'image' => 'nginx:alpine', + ]); + $this->otherServiceDatabase = ServiceDatabase::create([ + 'service_id' => $this->otherService->id, + 'name' => 'other-db', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]); + + $this->ownService = Service::factory()->create([ + 'server_id' => $this->serverA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => $this->destinationA->getMorphClass(), + 'environment_id' => $this->environmentA->id, + ]); + $this->ownServiceDatabase = ServiceDatabase::create([ + 'service_id' => $this->ownService->id, + 'name' => 'own-db', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]); + + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +test('does not open service application detail route from another team', function () { + $this->withoutExceptionHandling(); + + $this->get(route('project.service.index', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'service_uuid' => $this->otherService->uuid, + 'stack_service_uuid' => $this->otherServiceApplication->uuid, + ])); +})->throws(NotFoundHttpException::class); + +test('does not open service database backups route from another team', function () { + $this->withoutExceptionHandling(); + + $this->get(route('project.service.database.backups', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'service_uuid' => $this->otherService->uuid, + 'stack_service_uuid' => $this->otherServiceDatabase->uuid, + ])); +})->throws(NotFoundHttpException::class); + +test('does not resolve service database import component from another team', function () { + $component = app(DatabaseImport::class); + $component->parameters = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'service_uuid' => $this->otherService->uuid, + 'stack_service_uuid' => $this->otherServiceDatabase->uuid, + ]; + + $component->getContainers(); +})->throws(ModelNotFoundException::class); + +test('service heading does not hydrate with another team service', function () { + Livewire::test(Heading::class, ['service' => $this->otherService]); +})->throws(ModelNotFoundException::class); + +test('owner can still hydrate service heading with own service', function () { + Livewire::test(Heading::class, [ + 'service' => $this->ownService, + 'parameters' => [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'service_uuid' => $this->ownService->uuid, + ], + ]) + ->assertOk(); +});