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.
This commit is contained in:
Andras Bacsai 2026-04-19 11:52:52 +02:00
parent 57ea0764b8
commit a478ac66eb
18 changed files with 607 additions and 154 deletions

View file

@ -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);
}

View file

@ -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.');
}

View file

@ -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,
]);

View file

@ -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();

View file

@ -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();

View file

@ -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();

View file

@ -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,

View file

@ -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();

View file

@ -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(),
];

View file

@ -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.');

View file

@ -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) {

View file

@ -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.

View file

@ -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.

View file

@ -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;

View file

@ -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()) {

View file

@ -0,0 +1,106 @@
<?php
use App\Livewire\Storage\Resources as StorageResources;
use App\Models\InstanceSettings;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::unguarded(fn () => 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();
});
});

View file

@ -0,0 +1,297 @@
<?php
use App\Livewire\Destination\Show as DestinationShow;
use App\Livewire\Project\New\DockerCompose;
use App\Livewire\Project\New\DockerImage;
use App\Livewire\Project\New\GithubPrivateRepository;
use App\Livewire\Project\New\GithubPrivateRepositoryDeployKey;
use App\Livewire\Project\New\PublicGitRepository;
use App\Livewire\Project\New\SimpleDockerfile;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\SwarmDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::unguarded(fn () => 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'));
});
});

View file

@ -0,0 +1,96 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
// Team A (current actor)
$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' => '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);
});