From a478ac66eb7037837c178d64006f83a13eca12d2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:52:52 +0200 Subject: [PATCH 1/2] refactor: scope destination and resource lookups by current team Use find_destination_for_current_team helper across resource creation flows and the destination controller. Pass full destination objects to database creation helpers instead of UUIDs so team relationships are resolved consistently before the resource is created or linked. Add feature tests covering destination, backup storage, and resource proof lookups across teams. --- .../Controllers/Api/DatabasesController.php | 16 +- app/Livewire/Destination/Show.php | 16 +- app/Livewire/Project/New/DockerCompose.php | 14 +- app/Livewire/Project/New/DockerImage.php | 11 +- .../Project/New/GithubPrivateRepository.php | 11 +- .../New/GithubPrivateRepositoryDeployKey.php | 11 +- .../Project/New/PublicGitRepository.php | 19 +- app/Livewire/Project/New/SimpleDockerfile.php | 11 +- app/Livewire/Project/Resource/Create.php | 29 +- .../Project/Shared/ResourceOperations.php | 5 +- app/Livewire/Storage/Resources.php | 8 +- app/Models/StandaloneDocker.php | 10 + app/Models/SwarmDocker.php | 10 + bootstrap/helpers/databases.php | 51 ++- bootstrap/helpers/shared.php | 40 +-- tests/Feature/TeamScopedBackupStorageTest.php | 106 +++++++ tests/Feature/TeamScopedDestinationTest.php | 297 ++++++++++++++++++ .../Feature/TeamScopedResourceProofsTest.php | 96 ++++++ 18 files changed, 607 insertions(+), 154 deletions(-) create mode 100644 tests/Feature/TeamScopedBackupStorageTest.php create mode 100644 tests/Feature/TeamScopedDestinationTest.php create mode 100644 tests/Feature/TeamScopedResourceProofsTest.php diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 8e31a7051..f3783696d 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -1766,7 +1766,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('postgres_conf', $postgresConf); } - $database = create_standalone_postgresql($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_postgresql($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1821,7 +1821,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('mariadb_conf', $mariadbConf); } - $database = create_standalone_mariadb($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_mariadb($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1880,7 +1880,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('mysql_conf', $mysqlConf); } - $database = create_standalone_mysql($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_mysql($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1936,7 +1936,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('redis_conf', $redisConf); } - $database = create_standalone_redis($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_redis($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1973,7 +1973,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } removeUnnecessaryFieldsFromRequest($request); - $database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_dragonfly($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2022,7 +2022,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('keydb_conf', $keydbConf); } - $database = create_standalone_keydb($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_keydb($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2058,7 +2058,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) ], 422); } removeUnnecessaryFieldsFromRequest($request); - $database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_clickhouse($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2116,7 +2116,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('mongo_conf', $mongoConf); } - $database = create_standalone_mongodb($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_mongodb($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php index f2cdad074..9d55d7462 100644 --- a/app/Livewire/Destination/Show.php +++ b/app/Livewire/Destination/Show.php @@ -2,9 +2,7 @@ namespace App\Livewire\Destination; -use App\Models\Server; use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; @@ -29,16 +27,8 @@ class Show extends Component public function mount(string $destination_uuid) { try { - $destination = StandaloneDocker::whereUuid($destination_uuid)->first() ?? - SwarmDocker::whereUuid($destination_uuid)->firstOrFail(); - - $ownedByTeam = Server::ownedByCurrentTeam()->each(function ($server) use ($destination) { - if ($server->standaloneDockers->contains($destination) || $server->swarmDockers->contains($destination)) { - $this->destination = $destination; - $this->syncData(); - } - }); - if ($ownedByTeam === false) { + $destination = find_destination_for_current_team($destination_uuid); + if (! $destination) { return redirect()->route('destination.index'); } $this->destination = $destination; @@ -80,7 +70,7 @@ public function delete() try { $this->authorize('delete', $this->destination); - if ($this->destination->getMorphClass() === \App\Models\StandaloneDocker::class) { + if ($this->destination->getMorphClass() === StandaloneDocker::class) { if ($this->destination->attachedTo()) { return $this->dispatch('error', 'You must delete all resources before deleting this destination.'); } diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 2b92902c6..2cf0659bf 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -5,8 +5,6 @@ use App\Models\EnvironmentVariable; use App\Models\Project; use App\Models\Service; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use Livewire\Component; use Symfony\Component\Yaml\Yaml; @@ -31,7 +29,6 @@ public function mount() public function submit() { - $server_id = $this->query['server_id']; try { $this->validate([ 'dockerComposeRaw' => 'required', @@ -44,20 +41,17 @@ public function submit() $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); - $destination_uuid = $this->query['destination']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); $service = Service::create([ 'docker_compose_raw' => $this->dockerComposeRaw, 'environment_id' => $environment->id, - 'server_id' => (int) $server_id, + 'server_id' => $destination->server_id, 'destination_id' => $destination->id, 'destination_type' => $destination_class, ]); diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index 268333d07..b89ce2c6a 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -4,8 +4,6 @@ use App\Models\Application; use App\Models\Project; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use App\Services\DockerImageParser; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -111,13 +109,10 @@ public function submit() $parser = new DockerImageParser; $parser->parse($dockerImage); - $destination_uuid = $this->query['destination']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 0222008b0..86e407136 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -5,8 +5,6 @@ use App\Models\Application; use App\Models\GithubApp; use App\Models\Project; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use App\Rules\ValidGitBranch; use App\Support\ValidationPatterns; use Illuminate\Support\Facades\Http; @@ -178,13 +176,10 @@ public function submit() throw new \RuntimeException('Invalid repository data: '.$validator->errors()->first()); } - $destination_uuid = $this->query['destination']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index f8642d6fc..5a6f288b3 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -7,8 +7,6 @@ use App\Models\GitlabApp; use App\Models\PrivateKey; use App\Models\Project; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Support\ValidationPatterns; @@ -130,13 +128,10 @@ public function submit() { $this->validate(); try { - $destination_uuid = $this->query['destination']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index dbfa15a55..b350538ac 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -7,8 +7,6 @@ use App\Models\GitlabApp; use App\Models\Project; use App\Models\Service; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Support\ValidationPatterns; @@ -34,8 +32,6 @@ class PublicGitRepository extends Component public bool $isStatic = false; - public bool $checkCoolifyConfig = true; - public ?string $publish_directory = null; // In case of docker compose @@ -284,16 +280,13 @@ public function submit() throw new \RuntimeException('Invalid branch: '.$branchValidator->errors()->first('git_branch')); } - $destination_uuid = $this->query['destination']; + $destination_uuid = $this->query['destination'] ?? null; $project_uuid = $this->parameters['project_uuid']; $environment_uuid = $this->parameters['environment_uuid']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); @@ -371,12 +364,6 @@ public function submit() $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->fqdn = $fqdn; $application->save(); - if ($this->checkCoolifyConfig) { - // $config = loadConfigFromGit($this->repository_url, $this->git_branch, $this->base_directory, $this->query['server_id'], auth()->user()->currentTeam()->id); - // if ($config) { - // $application->setConfig($config); - // } - } return redirect()->route('project.application.configuration', [ 'application_uuid' => $application->uuid, diff --git a/app/Livewire/Project/New/SimpleDockerfile.php b/app/Livewire/Project/New/SimpleDockerfile.php index 1073157e6..f07948dba 100644 --- a/app/Livewire/Project/New/SimpleDockerfile.php +++ b/app/Livewire/Project/New/SimpleDockerfile.php @@ -5,8 +5,6 @@ use App\Models\Application; use App\Models\GithubApp; use App\Models\Project; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -35,13 +33,10 @@ public function submit() $this->validate([ 'dockerfile' => 'required', ]); - $destination_uuid = $this->query['destination']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php index 966c66a14..4619ddf37 100644 --- a/app/Livewire/Project/Resource/Create.php +++ b/app/Livewire/Project/Resource/Create.php @@ -4,7 +4,6 @@ use App\Models\EnvironmentVariable; use App\Models\Service; -use App\Models\StandaloneDocker; use Livewire\Component; class Create extends Component @@ -18,7 +17,6 @@ public function mount() $type = str(request()->query('type')); $destination_uuid = request()->query('destination'); - $server_id = request()->query('server_id'); $database_image = request()->query('database_image'); $project = currentTeam()->load(['projects'])->projects->where('uuid', request()->route('project_uuid'))->first(); @@ -30,7 +28,11 @@ public function mount() if (! $environment) { return redirect()->route('dashboard'); } - if (isset($type) && isset($destination_uuid) && isset($server_id)) { + if (isset($type) && isset($destination_uuid)) { + $destination = find_destination_for_current_team($destination_uuid); + if (! $destination) { + return redirect()->route('dashboard'); + } $services = get_service_templates(); if (in_array($type, DATABASE_TYPES)) { @@ -44,23 +46,23 @@ public function mount() } $database = create_standalone_postgresql( environmentId: $environment->id, - destinationUuid: $destination_uuid, + destination: $destination, databaseImage: $database_image ); } elseif ($type->value() === 'redis') { - $database = create_standalone_redis($environment->id, $destination_uuid); + $database = create_standalone_redis($environment->id, $destination); } elseif ($type->value() === 'mongodb') { - $database = create_standalone_mongodb($environment->id, $destination_uuid); + $database = create_standalone_mongodb($environment->id, $destination); } elseif ($type->value() === 'mysql') { - $database = create_standalone_mysql($environment->id, $destination_uuid); + $database = create_standalone_mysql($environment->id, $destination); } elseif ($type->value() === 'mariadb') { - $database = create_standalone_mariadb($environment->id, $destination_uuid); + $database = create_standalone_mariadb($environment->id, $destination); } elseif ($type->value() === 'keydb') { - $database = create_standalone_keydb($environment->id, $destination_uuid); + $database = create_standalone_keydb($environment->id, $destination); } elseif ($type->value() === 'dragonfly') { - $database = create_standalone_dragonfly($environment->id, $destination_uuid); + $database = create_standalone_dragonfly($environment->id, $destination); } elseif ($type->value() === 'clickhouse') { - $database = create_standalone_clickhouse($environment->id, $destination_uuid); + $database = create_standalone_clickhouse($environment->id, $destination); } return redirect()->route('project.database.configuration', [ @@ -69,7 +71,7 @@ public function mount() 'database_uuid' => $database->uuid, ]); } - if ($type->startsWith('one-click-service-') && ! is_null((int) $server_id)) { + if ($type->startsWith('one-click-service-')) { $oneClickServiceName = $type->after('one-click-service-')->value(); $oneClickService = data_get($services, "$oneClickServiceName.compose"); $oneClickDotEnvs = data_get($services, "$oneClickServiceName.envs", null); @@ -79,12 +81,11 @@ public function mount() }); } if ($oneClickService) { - $destination = StandaloneDocker::whereUuid($destination_uuid)->first(); $service_payload = [ 'docker_compose_raw' => base64_decode($oneClickService), 'environment_id' => $environment->id, 'service_type' => $oneClickServiceName, - 'server_id' => (int) $server_id, + 'server_id' => $destination->server_id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), ]; diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index f4813dd4c..2a8747c33 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -58,10 +58,9 @@ public function cloneTo($destination_id) { $this->authorize('update', $this->resource); - $teamScope = fn ($q) => $q->where('team_id', currentTeam()->id); - $new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id); + $new_destination = StandaloneDocker::ownedByCurrentTeam()->find($destination_id); if (! $new_destination) { - $new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id); + $new_destination = SwarmDocker::ownedByCurrentTeam()->find($destination_id); } if (! $new_destination) { return $this->addError('destination_id', 'Destination not found.'); diff --git a/app/Livewire/Storage/Resources.php b/app/Livewire/Storage/Resources.php index 643ecb3eb..0dad2d548 100644 --- a/app/Livewire/Storage/Resources.php +++ b/app/Livewire/Storage/Resources.php @@ -25,7 +25,9 @@ public function mount(): void public function disableS3(int $backupId): void { - $backup = ScheduledDatabaseBackup::findOrFail($backupId); + $backup = ScheduledDatabaseBackup::where('id', $backupId) + ->where('s3_storage_id', $this->storage->id) + ->firstOrFail(); $backup->update([ 'save_s3' => false, @@ -39,7 +41,9 @@ public function disableS3(int $backupId): void public function moveBackup(int $backupId): void { - $backup = ScheduledDatabaseBackup::findOrFail($backupId); + $backup = ScheduledDatabaseBackup::where('id', $backupId) + ->where('s3_storage_id', $this->storage->id) + ->firstOrFail(); $newStorageId = $this->selectedStorages[$backupId] ?? null; if (! $newStorageId || (int) $newStorageId === $this->storage->id) { diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index dcb349405..d6b4d1a1c 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -90,6 +90,16 @@ public function server() return $this->belongsTo(Server::class); } + public static function ownedByCurrentTeam() + { + return static::whereHas('server', fn ($q) => $q->whereTeamId(currentTeam()->id)); + } + + public static function ownedByCurrentTeamAPI(int $teamId) + { + return static::whereHas('server', fn ($q) => $q->whereTeamId($teamId)); + } + /** * Get the server attribute using identity map caching. * This intercepts lazy-loading to use cached Server lookups. diff --git a/app/Models/SwarmDocker.php b/app/Models/SwarmDocker.php index 134e36189..0e9620457 100644 --- a/app/Models/SwarmDocker.php +++ b/app/Models/SwarmDocker.php @@ -71,6 +71,16 @@ public function server() return $this->belongsTo(Server::class); } + public static function ownedByCurrentTeam() + { + return static::whereHas('server', fn ($q) => $q->whereTeamId(currentTeam()->id)); + } + + public static function ownedByCurrentTeamAPI(int $teamId) + { + return static::whereHas('server', fn ($q) => $q->whereTeamId($teamId)); + } + /** * Get the server attribute using identity map caching. * This intercepts lazy-loading to use cached Server lookups. diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 5df36db33..4d5e085f3 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -3,6 +3,7 @@ use App\Models\EnvironmentVariable; use App\Models\S3Storage; use App\Models\Server; +use App\Models\ServiceDatabase; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDocker; use App\Models\StandaloneDragonfly; @@ -12,18 +13,19 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use App\Models\SwarmDocker; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; use Visus\Cuid2\Cuid2; -function create_standalone_postgresql($environmentId, $destinationUuid, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql +function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql { - $destination = StandaloneDocker::where('uuid', $destinationUuid)->firstOrFail(); $database = new StandalonePostgresql; $database->uuid = (new Cuid2); $database->name = 'postgresql-database-'.$database->uuid; $database->image = $databaseImage; - $database->postgres_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->postgres_password = Str::password(length: 64, symbols: false); $database->environment_id = $environmentId; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -35,14 +37,13 @@ function create_standalone_postgresql($environmentId, $destinationUuid, ?array $ return $database; } -function create_standalone_redis($environment_id, $destination_uuid, ?array $otherData = null): StandaloneRedis +function create_standalone_redis($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneRedis { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneRedis; $database->uuid = (new Cuid2); $database->name = 'redis-database-'.$database->uuid; - $redis_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $redis_password = Str::password(length: 64, symbols: false); if ($otherData && isset($otherData['redis_password'])) { $redis_password = $otherData['redis_password']; unset($otherData['redis_password']); @@ -75,13 +76,12 @@ function create_standalone_redis($environment_id, $destination_uuid, ?array $oth return $database; } -function create_standalone_mongodb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMongodb +function create_standalone_mongodb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMongodb { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneMongodb; $database->uuid = (new Cuid2); $database->name = 'mongodb-database-'.$database->uuid; - $database->mongo_initdb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->mongo_initdb_root_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -93,14 +93,13 @@ function create_standalone_mongodb($environment_id, $destination_uuid, ?array $o return $database; } -function create_standalone_mysql($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMysql +function create_standalone_mysql($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMysql { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneMysql; $database->uuid = (new Cuid2); $database->name = 'mysql-database-'.$database->uuid; - $database->mysql_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false); - $database->mysql_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->mysql_root_password = Str::password(length: 64, symbols: false); + $database->mysql_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -112,14 +111,13 @@ function create_standalone_mysql($environment_id, $destination_uuid, ?array $oth return $database; } -function create_standalone_mariadb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMariadb +function create_standalone_mariadb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMariadb { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneMariadb; $database->uuid = (new Cuid2); $database->name = 'mariadb-database-'.$database->uuid; - $database->mariadb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false); - $database->mariadb_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->mariadb_root_password = Str::password(length: 64, symbols: false); + $database->mariadb_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -131,13 +129,12 @@ function create_standalone_mariadb($environment_id, $destination_uuid, ?array $o return $database; } -function create_standalone_keydb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneKeydb +function create_standalone_keydb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneKeydb { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneKeydb; $database->uuid = (new Cuid2); $database->name = 'keydb-database-'.$database->uuid; - $database->keydb_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->keydb_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -149,13 +146,12 @@ function create_standalone_keydb($environment_id, $destination_uuid, ?array $oth return $database; } -function create_standalone_dragonfly($environment_id, $destination_uuid, ?array $otherData = null): StandaloneDragonfly +function create_standalone_dragonfly($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneDragonfly { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneDragonfly; $database->uuid = (new Cuid2); $database->name = 'dragonfly-database-'.$database->uuid; - $database->dragonfly_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->dragonfly_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -167,13 +163,12 @@ function create_standalone_dragonfly($environment_id, $destination_uuid, ?array return $database; } -function create_standalone_clickhouse($environment_id, $destination_uuid, ?array $otherData = null): StandaloneClickhouse +function create_standalone_clickhouse($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneClickhouse { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneClickhouse; $database->uuid = (new Cuid2); $database->name = 'clickhouse-database-'.$database->uuid; - $database->clickhouse_admin_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->clickhouse_admin_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -279,7 +274,7 @@ function removeOldBackups($backup): void ->whereNull('s3_uploaded') ->delete(); - } catch (\Exception $e) { + } catch (Exception $e) { throw $e; } } @@ -345,7 +340,7 @@ function deleteOldBackupsLocally($backup): Collection $processedBackups = collect(); $server = null; - if ($backup->database_type === \App\Models\ServiceDatabase::class) { + if ($backup->database_type === ServiceDatabase::class) { $server = $backup->database->service->server; } else { $server = $backup->database->destination->server; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index cd773f6a9..88a2c645e 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -18,6 +18,7 @@ use App\Models\ServiceDatabase; use App\Models\SharedEnvironmentVariable; use App\Models\StandaloneClickhouse; +use App\Models\StandaloneDocker; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; use App\Models\StandaloneMariadb; @@ -25,6 +26,7 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use App\Models\SwarmDocker; use App\Models\Team; use App\Models\User; use Carbon\CarbonImmutable; @@ -259,6 +261,16 @@ function currentTeam() return Auth::user()?->currentTeam() ?? null; } +function find_destination_for_current_team(?string $uuid): StandaloneDocker|SwarmDocker|null +{ + if (blank($uuid) || ! currentTeam()) { + return null; + } + + return StandaloneDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first() + ?? SwarmDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first(); +} + function showBoarding(): bool { if (isDev()) { @@ -3489,34 +3501,6 @@ function getHelperVersion(): string return config('constants.coolify.helper_version'); } -function loadConfigFromGit(string $repository, string $branch, string $base_directory, int $server_id, int $team_id) -{ - $server = Server::find($server_id)->where('team_id', $team_id)->first(); - if (! $server) { - return; - } - $uuid = new Cuid2; - $cloneCommand = "git clone --no-checkout -b $branch $repository ."; - $workdir = rtrim($base_directory, '/'); - $fileList = collect([".$workdir/coolify.json"]); - $commands = collect([ - "rm -rf /tmp/{$uuid}", - "mkdir -p /tmp/{$uuid}", - "cd /tmp/{$uuid}", - $cloneCommand, - 'git sparse-checkout init --cone', - "git sparse-checkout set {$fileList->implode(' ')}", - 'git read-tree -mu HEAD', - "cat .$workdir/coolify.json", - 'rm -rf /tmp/{$uuid}', - ]); - try { - return instant_remote_process($commands, $server); - } catch (Exception) { - // continue - } -} - function loggy($message = null, array $context = []) { if (! isDev()) { diff --git a/tests/Feature/TeamScopedBackupStorageTest.php b/tests/Feature/TeamScopedBackupStorageTest.php new file mode 100644 index 000000000..57a065ae8 --- /dev/null +++ b/tests/Feature/TeamScopedBackupStorageTest.php @@ -0,0 +1,106 @@ + InstanceSettings::query()->create(['id' => 0])); + + $this->userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + + $this->userB = User::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->userB->teams()->attach($this->teamB, ['role' => 'owner']); + + $this->storageA = S3Storage::unguarded(fn () => S3Storage::create([ + 'uuid' => fake()->uuid(), + 'name' => 'storage-a-'.fake()->unique()->word(), + 'region' => 'us-east-1', + 'key' => 'key-a', + 'secret' => 'secret-a', + 'bucket' => 'bucket-a', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $this->teamA->id, + ])); + + $this->storageB = S3Storage::unguarded(fn () => S3Storage::create([ + 'uuid' => fake()->uuid(), + 'name' => 'storage-b-'.fake()->unique()->word(), + 'region' => 'us-east-1', + 'key' => 'key-b', + 'secret' => 'secret-b', + 'bucket' => 'bucket-b', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $this->teamB->id, + ])); + + $this->backupA = ScheduledDatabaseBackup::create([ + 'uuid' => fake()->uuid(), + 'team_id' => $this->teamA->id, + 'enabled' => true, + 'save_s3' => true, + 'frequency' => '0 0 * * *', + 'database_type' => 'App\\Models\\StandalonePostgresql', + 'database_id' => 1, + 's3_storage_id' => $this->storageA->id, + ]); + + $this->backupB = ScheduledDatabaseBackup::create([ + 'uuid' => fake()->uuid(), + 'team_id' => $this->teamB->id, + 'enabled' => true, + 'save_s3' => true, + 'frequency' => '0 0 * * *', + 'database_type' => 'App\\Models\\StandalonePostgresql', + 'database_id' => 2, + 's3_storage_id' => $this->storageB->id, + ]); + + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +describe('Storage/Resources team-scoped backup access', function () { + test('disableS3 on other team backup throws and leaves row unchanged', function () { + expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA]) + ->call('disableS3', $this->backupB->id)) + ->toThrow(ModelNotFoundException::class); + + $this->backupB->refresh(); + expect((bool) $this->backupB->save_s3)->toBeTrue(); + expect($this->backupB->s3_storage_id)->toBe($this->storageB->id); + }); + + test('moveBackup on other team backup throws and leaves row unchanged', function () { + expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA]) + ->set('selectedStorages', [$this->backupB->id => $this->storageA->id]) + ->call('moveBackup', $this->backupB->id)) + ->toThrow(ModelNotFoundException::class); + + $this->backupB->refresh(); + expect($this->backupB->s3_storage_id)->toBe($this->storageB->id); + }); + + test('disableS3 on own backup succeeds', function () { + Livewire::test(StorageResources::class, ['storage' => $this->storageA]) + ->call('disableS3', $this->backupA->id); + + $this->backupA->refresh(); + expect((bool) $this->backupA->save_s3)->toBeFalse(); + expect($this->backupA->s3_storage_id)->toBeNull(); + }); +}); diff --git a/tests/Feature/TeamScopedDestinationTest.php b/tests/Feature/TeamScopedDestinationTest.php new file mode 100644 index 000000000..bdac0251d --- /dev/null +++ b/tests/Feature/TeamScopedDestinationTest.php @@ -0,0 +1,297 @@ + InstanceSettings::query()->create(['id' => 0])); + + $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->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + $this->destinationA = StandaloneDocker::factory()->create([ + 'server_id' => $this->serverA->id, + 'name' => 'dest-a-'.fake()->unique()->word(), + 'network' => 'coolify-a-'.fake()->unique()->word(), + ]); + + $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->projectB = Project::factory()->create(['team_id' => $this->teamB->id]); + $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]); + $this->destinationB = StandaloneDocker::factory()->create([ + 'server_id' => $this->serverB->id, + 'name' => 'dest-b-'.fake()->unique()->word(), + 'network' => 'coolify-b-'.fake()->unique()->word(), + ]); + $this->swarmDestinationB = SwarmDocker::create([ + 'uuid' => fake()->uuid(), + 'name' => 'swarm-b-'.fake()->unique()->word(), + 'network' => 'swarm-b-'.fake()->unique()->word(), + 'server_id' => $this->serverB->id, + ]); + + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +describe('find_destination_for_current_team helper', function () { + test('returns null for other team destination UUID', function () { + expect(find_destination_for_current_team($this->destinationB->uuid))->toBeNull(); + }); + + test('returns null for other team swarm destination UUID', function () { + expect(find_destination_for_current_team($this->swarmDestinationB->uuid))->toBeNull(); + }); + + test('returns own team destination', function () { + $found = find_destination_for_current_team($this->destinationA->uuid); + expect($found)->not->toBeNull(); + expect($found->id)->toBe($this->destinationA->id); + }); + + test('returns null for blank uuid', function () { + expect(find_destination_for_current_team(null))->toBeNull(); + expect(find_destination_for_current_team(''))->toBeNull(); + }); +}); + +describe('SimpleDockerfile destination team scope', function () { + test('submit with other team destination throws and creates no application', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + request()->headers->set('referer', route('project.resource.create', $routeParams).'?destination='.$this->destinationB->uuid); + + $before = Application::count(); + + expect(fn () => Livewire::withUrlParams(['destination' => $this->destinationB->uuid]) + ->test(SimpleDockerfile::class, $routeParams) + ->set('dockerfile', "FROM nginx\nCMD [\"nginx\"]\n") + ->call('submit')) + ->toThrow(Exception::class, 'Destination not found.'); + + expect(Application::count())->toBe($before); + }); +}); + +describe('DockerImage destination team scope', function () { + test('submit with other team destination throws and creates no application', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + $before = Application::count(); + + expect(fn () => Livewire::withUrlParams(['destination' => $this->destinationB->uuid]) + ->test(DockerImage::class, $routeParams) + ->set('imageName', 'nginx') + ->set('imageTag', 'latest') + ->call('submit')) + ->toThrow(Exception::class, 'Destination not found.'); + + expect(Application::count())->toBe($before); + }); + + test('submit with other team swarm destination throws', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + expect(fn () => Livewire::withUrlParams(['destination' => $this->swarmDestinationB->uuid]) + ->test(DockerImage::class, $routeParams) + ->set('imageName', 'nginx') + ->set('imageTag', 'latest') + ->call('submit')) + ->toThrow(Exception::class, 'Destination not found.'); + }); +}); + +describe('DockerCompose destination + server_id team scope', function () { + test('submit with other team destination throws and creates no service', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + $before = Service::count(); + + Livewire::withUrlParams([ + 'destination' => $this->destinationB->uuid, + 'server_id' => $this->serverB->id, + ]) + ->test(DockerCompose::class, $routeParams) + ->set('dockerComposeRaw', "services:\n app:\n image: nginx\n") + ->call('submit'); + + expect(Service::count())->toBe($before); + }); + +}); + +describe('PublicGitRepository destination team scope', function () { + test('submit with other team destination creates no application', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + $before = Application::count(); + + try { + Livewire::withUrlParams(['destination' => $this->destinationB->uuid]) + ->test(PublicGitRepository::class, $routeParams) + ->set('repository_url', 'https://github.com/coollabsio/coolify') + ->set('git_repository', 'coollabsio/coolify') + ->set('git_branch', 'main') + ->set('port', 3000) + ->set('build_pack', 'nixpacks') + ->set('git_source', 'other') + ->call('submit'); + } catch (Throwable $e) { + // submit wraps errors via handleError; count assertion below is source of truth + } + + expect(Application::count())->toBe($before); + }); +}); + +describe('GithubPrivateRepository destination team scope', function () { + test('submit with other team destination throws and creates no application', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + $before = Application::count(); + + try { + Livewire::withUrlParams(['destination' => $this->destinationB->uuid]) + ->test(GithubPrivateRepository::class, $routeParams) + ->call('submit'); + } catch (Throwable $e) { + // expected + } + + expect(Application::count())->toBe($before); + }); +}); + +describe('GithubPrivateRepositoryDeployKey destination team scope', function () { + test('submit with other team destination throws and creates no application', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + $before = Application::count(); + + try { + Livewire::withUrlParams(['destination' => $this->destinationB->uuid]) + ->test(GithubPrivateRepositoryDeployKey::class, $routeParams) + ->call('submit'); + } catch (Throwable $e) { + // expected + } + + expect(Application::count())->toBe($before); + }); +}); + +describe('Resource/Create database destination team scope', function () { + test('mount with other team destination does not create database', function () { + $before = StandalonePostgresql::count(); + + $url = route('project.resource.create', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]).'?type=postgresql&destination='.$this->destinationB->uuid.'&server_id='.$this->serverB->id.'&database_image=postgres:16-alpine'; + + $this->get($url); + + expect(StandalonePostgresql::count())->toBe($before); + }); + +}); + +describe('StandaloneDocker/SwarmDocker ownedByCurrentTeam scope', function () { + test('StandaloneDocker::ownedByCurrentTeam excludes other team destinations', function () { + expect(StandaloneDocker::ownedByCurrentTeam()->where('uuid', $this->destinationB->uuid)->first())->toBeNull(); + }); + + test('SwarmDocker::ownedByCurrentTeam excludes other team destinations', function () { + expect(SwarmDocker::ownedByCurrentTeam()->where('uuid', $this->swarmDestinationB->uuid)->first())->toBeNull(); + }); + + test('StandaloneDocker::ownedByCurrentTeam returns own destination', function () { + $found = StandaloneDocker::ownedByCurrentTeam()->where('uuid', $this->destinationA->uuid)->first(); + expect($found)->not->toBeNull(); + expect($found->id)->toBe($this->destinationA->id); + }); + + test('StandaloneDocker::ownedByCurrentTeamAPI scopes by explicit team id', function () { + expect(StandaloneDocker::ownedByCurrentTeamAPI($this->teamA->id)->where('uuid', $this->destinationB->uuid)->first())->toBeNull(); + expect(StandaloneDocker::ownedByCurrentTeamAPI($this->teamB->id)->where('uuid', $this->destinationB->uuid)->first()?->id)->toBe($this->destinationB->id); + }); + + test('SwarmDocker::ownedByCurrentTeamAPI scopes by explicit team id', function () { + expect(SwarmDocker::ownedByCurrentTeamAPI($this->teamA->id)->where('uuid', $this->swarmDestinationB->uuid)->first())->toBeNull(); + expect(SwarmDocker::ownedByCurrentTeamAPI($this->teamB->id)->where('uuid', $this->swarmDestinationB->uuid)->first()?->id)->toBe($this->swarmDestinationB->id); + }); +}); + +describe('Destination/Show team scope', function () { + test('mount with other team destination UUID redirects to index', function () { + $component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationB->uuid]); + + expect($component->get('destination'))->toBeNull(); + $component->assertRedirect(route('destination.index')); + }); + + test('mount with own destination UUID loads it', function () { + $component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationA->uuid]); + + expect($component->get('destination'))->not->toBeNull(); + expect($component->get('destination')->id)->toBe($this->destinationA->id); + }); + + test('mount with other team swarm destination UUID redirects to index', function () { + $component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->swarmDestinationB->uuid]); + + expect($component->get('destination'))->toBeNull(); + $component->assertRedirect(route('destination.index')); + }); +}); diff --git a/tests/Feature/TeamScopedResourceProofsTest.php b/tests/Feature/TeamScopedResourceProofsTest.php new file mode 100644 index 000000000..b56fbd60e --- /dev/null +++ b/tests/Feature/TeamScopedResourceProofsTest.php @@ -0,0 +1,96 @@ +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' => 'net-a-'.fake()->uuid()]); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + + // Team B (other team) + $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' => 'net-b-'.fake()->uuid()]); + $this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]); + $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]); + + // Authenticate as Team A + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +test('unscoped Project lookup returns another teams project', function () { + $project = Project::where('uuid', $this->projectB->uuid)->first(); + + expect($project)->not->toBeNull() + ->and($project->team_id)->toBe($this->teamB->id) + ->and($project->team_id)->not->toBe($this->teamA->id); +}); + +test('unscoped StandaloneDocker lookup returns another teams destination', function () { + $dest = StandaloneDocker::where('uuid', $this->destinationB->uuid)->first(); + + expect($dest)->not->toBeNull() + ->and($dest->server->team_id)->toBe($this->teamB->id); +}); + +test('ownedByCurrentTeam scope blocks other-team Project access', function () { + expect(Project::ownedByCurrentTeam()->where('uuid', $this->projectB->uuid)->first())->toBeNull(); +}); + +test('ownedByCurrentTeam scope allows own Project access', function () { + expect(Project::ownedByCurrentTeam()->where('uuid', $this->projectA->uuid)->first())->not->toBeNull(); +}); + +test('Team A can create Application in Team B environment via unscoped lookups', function () { + $destination = StandaloneDocker::where('uuid', $this->destinationB->uuid)->first(); + $project = Project::where('uuid', $this->projectB->uuid)->first(); + $environment = $project->load(['environments'])->environments->where('uuid', $this->environmentB->uuid)->first(); + + $application = Application::create([ + 'name' => 'team-scope-test-canary', + 'repository_project_id' => 0, + 'git_repository' => 'coollabsio/coolify', + 'git_branch' => 'main', + 'build_pack' => 'dockerfile', + 'dockerfile' => "FROM alpine\nCMD echo hello", + 'ports_exposes' => 80, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'health_check_enabled' => false, + 'source_id' => 0, + 'source_type' => GithubApp::class, + ]); + + expect($application->environment_id)->toBe($this->environmentB->id) + ->and($application->destination_id)->toBe($this->destinationB->id) + ->and($application->environment->project->team->id)->toBe($this->teamB->id) + ->and($application->environment->project->team->id)->not->toBe($this->teamA->id); +}); + +test('resource creation page loads with another teams project UUID', function () { + $response = $this->get(route('project.resource.create', [ + 'project_uuid' => $this->projectB->uuid, + 'environment_uuid' => $this->environmentB->uuid, + ])); + + expect($response->status())->not->toBe(403); +}); From f77cc91b831b3f73ff06278152f5decc3ccf3006 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:55:36 +0200 Subject: [PATCH 2/2] refactor(admin): use named routes for admin index navigation Replace Referer-based redirects in Admin Index back() and switchUser() with named routes (admin.index and dashboard) for consistent navigation behavior independent of the request header. Add tests verifying back() returns to admin.index, switchUser routes to the dashboard, and the Referer header is no longer consulted. --- app/Livewire/Admin/Index.php | 4 +- .../Feature/AdminAccessAuthorizationTest.php | 47 +++++++++++++++++-- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/app/Livewire/Admin/Index.php b/app/Livewire/Admin/Index.php index d1345e7bf..4d22047cc 100644 --- a/app/Livewire/Admin/Index.php +++ b/app/Livewire/Admin/Index.php @@ -37,7 +37,7 @@ public function back() Auth::login($user); refreshSession($team_to_switch_to); - return redirect(request()->header('Referer')); + return redirect()->route('admin.index'); } } @@ -70,7 +70,7 @@ public function switchUser(int $user_id) Auth::login($user); refreshSession($team_to_switch_to); - return redirect(request()->header('Referer')); + return redirect()->route('dashboard'); } private function authorizeAdminAccess(): void diff --git a/tests/Feature/AdminAccessAuthorizationTest.php b/tests/Feature/AdminAccessAuthorizationTest.php index 4840bc4dd..97895ecda 100644 --- a/tests/Feature/AdminAccessAuthorizationTest.php +++ b/tests/Feature/AdminAccessAuthorizationTest.php @@ -1,6 +1,7 @@ set('constants.coolify.self_hosted', false); - $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); $rootUser = User::factory()->create(['id' => 0]); - $rootTeam->members()->attach($rootUser->id, ['role' => 'admin']); + $rootTeam = Team::find(0); $targetUser = User::factory()->create(); $targetTeam = Team::factory()->create(); @@ -84,7 +85,47 @@ Livewire::test(AdminIndex::class) ->assertOk() ->call('switchUser', $targetUser->id) - ->assertRedirect(); + ->assertRedirect(route('dashboard')); +}); + +test('back() redirects impersonator to admin index and clears session', function () { + config()->set('constants.coolify.self_hosted', false); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); + $rootUser = User::factory()->create(['id' => 0]); + $rootTeam = Team::find(0); + + $this->actingAs($rootUser); + session([ + 'currentTeam' => ['id' => $rootTeam->id], + 'impersonating' => true, + ]); + + Livewire::test(AdminIndex::class) + ->call('back') + ->assertRedirect(route('admin.index')); + + expect(session('impersonating'))->toBeNull(); +}); + +test('switchUser ignores Referer header and uses dashboard route', function () { + config()->set('constants.coolify.self_hosted', false); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); + $rootUser = User::factory()->create(['id' => 0]); + $rootTeam = Team::find(0); + + $targetUser = User::factory()->create(); + $targetTeam = Team::factory()->create(); + $targetTeam->members()->attach($targetUser->id, ['role' => 'admin']); + + $this->actingAs($rootUser); + session(['currentTeam' => ['id' => $rootTeam->id]]); + + Livewire::withHeaders(['Referer' => 'https://example.com/elsewhere']) + ->test(AdminIndex::class) + ->call('switchUser', $targetUser->id) + ->assertRedirect(route('dashboard')); }); test('switchUser rejects non-root user', function () {