From 88efc0716e5165a675b7901a1413d1126ea1fc77 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:23:04 +0200 Subject: [PATCH] fix: enforce resource team isolation and preserve ClickHouse data Scope resource permissions and related records to the owning team across team switches, cloning, deployments, and resource operations. Merge ClickHouse volume mounts, preserve anonymous data volumes on stop, and require adopting current data before starting or restarting. Fix file storage deletion checks and add regression coverage. --- .ai/lessons.md | 2 + AGENTS.md | 6 + app/Actions/Database/StartClickhouse.php | 18 +- app/Actions/Database/StartDatabase.php | 4 + app/Actions/Database/StopDatabase.php | 7 +- app/Exceptions/DatabaseStartException.php | 5 + .../Controllers/Api/DatabasesController.php | 2 +- app/Http/Controllers/Api/DeployController.php | 4 +- app/Jobs/DeleteResourceJob.php | 2 +- app/Livewire/Destination/New/Docker.php | 2 +- .../Project/Application/DeploymentNavbar.php | 12 +- app/Livewire/Project/Application/General.php | 2 +- .../Project/Application/PreviewDomains.php | 2 +- app/Livewire/Project/Application/Source.php | 11 +- app/Livewire/Project/CloneMe.php | 603 +++++++++--------- .../Project/Database/Backup/Execution.php | 6 +- app/Livewire/Project/Database/BackupEdit.php | 17 +- .../Project/Database/Clickhouse/General.php | 27 + .../Project/Service/BackupExecutions.php | 6 +- .../Project/Service/DatabaseBackups.php | 6 +- app/Livewire/Project/Service/Index.php | 17 +- .../Project/Service/VolumeBackup/Index.php | 7 +- .../Project/Shared/ConfigurationChecker.php | 3 +- app/Livewire/Project/Shared/Destination.php | 36 +- .../Shared/EnvironmentVariable/Add.php | 2 +- .../Shared/EnvironmentVariable/All.php | 18 +- .../Shared/EnvironmentVariable/Show.php | 15 +- .../Project/Shared/ResourceOperations.php | 52 +- .../Project/Shared/ScheduledTask/Add.php | 2 +- app/Livewire/Project/Shared/Tags.php | 20 +- app/Livewire/Project/Show.php | 2 +- .../Server/CloudProviderToken/Show.php | 8 +- app/Livewire/Server/Destinations.php | 3 +- app/Livewire/Server/PrivateKey/Show.php | 14 +- app/Livewire/Server/Show.php | 6 +- .../SharedVariables/Environment/Show.php | 10 +- app/Livewire/SharedVariables/Project/Show.php | 10 +- app/Livewire/SharedVariables/Server/Show.php | 10 +- app/Livewire/SharedVariables/Team/Index.php | 10 +- app/Livewire/Source/Github/Change.php | 2 +- app/Models/StandaloneClickhouse.php | 54 ++ bootstrap/helpers/applications.php | 10 +- bootstrap/helpers/remoteProcess.php | 7 +- .../project/application/general.blade.php | 2 +- .../database/clickhouse/general.blade.php | 23 + .../service/volume-backup/index.blade.php | 4 +- .../project/shared/storages/all.blade.php | 5 - .../Application/GithubSourceChangeTest.php | 48 ++ .../ApplicationSourceCrossTeamTest.php | 74 +++ .../Authorization/CloneMeCrossTenantTest.php | 178 ++++++ .../EnvironmentVariableValueHidingTest.php | 56 ++ .../ProjectEnvironmentAuthorizationTest.php | 18 + .../ResourceOperationsCrossTenantTest.php | 145 +++++ tests/Feature/BackupEditValidationTest.php | 24 +- .../BackupS3StorageListResourceTeamTest.php | 130 ++++ .../Feature/CloudProviderLinkDropdownTest.php | 17 + tests/Feature/CloudProviderTokenShowTest.php | 39 ++ .../CrossTeamDestinationAttachTest.php | 62 ++ .../DeploymentCancellationCleanupTest.php | 25 + .../DeploymentDebugLogVisibilityTest.php | 88 +++ .../AutocompleteResourceTeamTest.php | 76 +++ .../Livewire/ConfigurationCheckerTest.php | 58 ++ .../ManagedDnsRecordEditReleaseTest.php | 64 ++ .../PersistentStorageVolumesLayoutTest.php | 2 +- .../ResourceTeamAfterTeamSwitchTest.php | 198 ++++++ tests/Feature/ServerDestinationsPageTest.php | 41 ++ .../Feature/ServerPrivateKeyDropdownTest.php | 50 ++ tests/Feature/SharedVariableDevViewTest.php | 59 ++ tests/Feature/StartClickhouseVolumesTest.php | 176 +++++ tests/Feature/StorageApiTest.php | 20 + tests/Feature/TagQuickAddTest.php | 12 + 71 files changed, 2335 insertions(+), 421 deletions(-) create mode 100644 tests/Feature/Authorization/CloneMeCrossTenantTest.php create mode 100644 tests/Feature/BackupS3StorageListResourceTeamTest.php create mode 100644 tests/Feature/DeploymentDebugLogVisibilityTest.php create mode 100644 tests/Feature/EnvironmentVariable/AutocompleteResourceTeamTest.php create mode 100644 tests/Feature/ResourceTeamAfterTeamSwitchTest.php create mode 100644 tests/Feature/StartClickhouseVolumesTest.php diff --git a/.ai/lessons.md b/.ai/lessons.md index 84b871b1f7..3d2c6a1e68 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -7,10 +7,12 @@ - Use the same regression test before and after the production change so the result shows the behavior difference. - Several dev instances can run from one checkout (see `./scripts/dev urls`). After you add a migration, run it on each running instance of that checkout, or those instances fail on the new code. - The checkout is shared with other sessions. When the full suite fails, rerun each failing file alone and compare with a clean `git archive HEAD` copy before you connect a failure to your change. +- Run seeders and other artisan commands that write SSH keys in a dev container as `www-data` (`docker exec -u www-data`). Root-owned files in `storage/app/ssh` make every SSH call from the web process fail. - `sshd` keeps the login shell of an open multiplexed SSH connection. Call `SshMultiplexingHelper::removeMuxFile()` before a live test of SSH or login-shell behavior. - SSH retries replay the full command batch. Only exit code 255 is an SSH transport failure; other exit codes come from the remote command, so do not retry them. Keep batches safe to run twice (for example `docker rm -f || true` before `docker run --name `). - Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open. - Browser `click('Text')` can match a hidden element first (for example inactive modal steps) and wait forever; use `button:visible:has-text("Text")`. +- Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only. - Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`. ## Verify the complete user flow diff --git a/AGENTS.md b/AGENTS.md index 8fd7b21092..fa5c428160 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,6 +150,12 @@ Because the "server" and the test share one PHP process, they share the phpunit - Multi-tenancy via Teams — team auto-initializes notification settings on creation - Authorize every server-side read and mutation where access can vary by user, role, team, or resource. Use policies, gates, or `$this->authorize(...)`; never rely on hidden Blade/Livewire controls such as `@can` for security. - Scope queries to the current team before returning records. Treat route and model identifiers as untrusted, and prevent users from reading or changing resources owned by another team. +- Use the session team (`currentTeam()`, `Model::ownedByCurrentTeam()`) only to find or open records, for "my team" lists, to create new top-level records, and on team pages. A user can switch teams in another tab while a page stays open, and every user owns a personal team. +- Once a component, action, or controller works on a loaded resource, use that resource's team for everything after it: + - Roles and redaction: use the policy (`$this->authorize('update', $resource)`) or `auth()->user()->isAdminOfTeam($resource->team()->id)`. Never use `isMember()`, `isAdmin()`, or `role()` for resource permissions; they read the role in the session team. + - Related records the user can pick (S3 storages, keys, servers, sources, tags, tokens): query with `->where('team_id', $resource->team()->id)` and validate submitted IDs against that team. + - New rows that belong to the resource: set `team_id` from the resource's team, not `currentTeam()->id`. +- Test this with a user who is a member of the resource's team and the owner of another team, with the session switched to the other team after mount. - Apply authorization consistently across Livewire actions, API and web controllers, actions, downloads, exports, search, event listeners, and any other path that exposes or changes protected data. - Default to denying access when a policy or ownership relationship is missing or ambiguous. Members must not gain access to administrative, credential, security, billing, or instance-wide data merely because they belong to the team. - Add authorization regression tests for protected changes. Cover permitted access, member restrictions where applicable, and cross-team access; verify unauthorized reads and writes return `403` or otherwise reveal no protected data. diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index 7dde9ac21b..f3831125a1 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -84,13 +84,23 @@ class StartClickhouse if (count($this->database->ports_mappings_array) > 0) { $docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array; } + + $docker_compose['services'][$container_name]['volumes'] ??= []; + if (count($persistent_storages) > 0) { - $docker_compose['services'][$container_name]['volumes'] = $persistent_storages; + $docker_compose['services'][$container_name]['volumes'] = array_merge( + $docker_compose['services'][$container_name]['volumes'], + $persistent_storages + ); } + if (count($persistent_file_volumes) > 0) { - $docker_compose['services'][$container_name]['volumes'] = $persistent_file_volumes->map(function ($item) { - return "$item->fs_path:$item->mount_path"; - })->toArray(); + $docker_compose['services'][$container_name]['volumes'] = array_merge( + $docker_compose['services'][$container_name]['volumes'], + $persistent_file_volumes->map(function ($item) { + return "$item->fs_path:$item->mount_path"; + })->toArray() + ); } if (count($volume_names) > 0) { $docker_compose['volumes'] = $volume_names; diff --git a/app/Actions/Database/StartDatabase.php b/app/Actions/Database/StartDatabase.php index 75d2bca5ce..7a0a0a556d 100644 --- a/app/Actions/Database/StartDatabase.php +++ b/app/Actions/Database/StartDatabase.php @@ -267,6 +267,10 @@ class StartDatabase */ public static function prerequisiteError(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite $database): ?string { + if ($database instanceof StandaloneClickhouse && ($volumeName = $database->anonymousDataVolume()) !== null) { + return DatabaseStartException::clickhouseDataInAnonymousVolume($volumeName)->getMessage(); + } + if (! $database->enable_ssl) { return null; } diff --git a/app/Actions/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php index ecdffdeda3..1d1e5c0855 100644 --- a/app/Actions/Database/StopDatabase.php +++ b/app/Actions/Database/StopDatabase.php @@ -20,7 +20,7 @@ class StopDatabase { use AsAction; - public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite $database, bool $dockerCleanup = true, bool $resetRestartCount = true, bool $removeContainer = true): string + public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite $database, bool $dockerCleanup = true, bool $resetRestartCount = true, bool $removeContainer = true, bool $keepAnonymousDataVolume = true): string { try { $server = $database->destination->server; @@ -28,6 +28,11 @@ class StopDatabase return 'Server is not functional'; } + // A removed container loses the link to the unnamed volume that holds the current data. + if ($removeContainer && $keepAnonymousDataVolume && $database instanceof StandaloneClickhouse && $database->anonymousDataVolume() !== null) { + $removeContainer = false; + } + $this->stopContainer($database, $database->uuid, 30, $removeContainer); // Reset restart tracking when database is manually stopped diff --git a/app/Exceptions/DatabaseStartException.php b/app/Exceptions/DatabaseStartException.php index 8dc54fb3ea..8f41dad221 100644 --- a/app/Exceptions/DatabaseStartException.php +++ b/app/Exceptions/DatabaseStartException.php @@ -15,6 +15,11 @@ class DatabaseStartException extends Exception return new static('No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); } + public static function clickhouseDataInAnonymousVolume(string $volumeName): static + { + return new static("This ClickHouse database keeps its current data in the unnamed Docker volume {$volumeName}, not in its data volume. A start or restart would use the data volume, and the current data would seem lost. On the database's General page, click \"Keep current data\" first."); + } + public static function startCommandsDidNotRun(): static { return new static('Database start ended without running the start commands.'); diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index b32a16fc61..84486f6a9f 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -4802,7 +4802,7 @@ class DatabasesController extends Controller ], 422); } - if ($storage->isSharedWithAnotherResource()) { + if ($storage instanceof LocalPersistentVolume && $storage->isSharedWithAnotherResource()) { return response()->json([ 'message' => 'This volume is mounted by an application. Unlink it on the SQLite database page first.', ], 422); diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index 419366d95f..b58a72d6ff 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -459,8 +459,8 @@ class DeployController extends Controller // $message->push("Tag {$tag} not found."); continue; } - $applications = $found_tag->applications()->get(); - $services = $found_tag->services()->get(); + $applications = $found_tag->applications()->whereRelation('environment.project', 'team_id', $team_id)->get(); + $services = $found_tag->services()->whereRelation('environment.project', 'team_id', $team_id)->get(); if ($applications->count() === 0 && $services->count() === 0) { $message->push("No resources found for tag {$tag}."); diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index 3d0e62e766..a5df1ebfa4 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -79,7 +79,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue case 'standalone-dragonfly': case 'standalone-clickhouse': case 'standalone-sqlite': - StopDatabase::run($this->resource, dockerCleanup: $this->dockerCleanup); + StopDatabase::run($this->resource, dockerCleanup: $this->dockerCleanup, keepAnonymousDataVolume: false); break; case 'service': StopService::run($this->resource, $this->deleteConnectedNetworks, $this->dockerCleanup); diff --git a/app/Livewire/Destination/New/Docker.php b/app/Livewire/Destination/New/Docker.php index 702bc29f4f..49da69911b 100644 --- a/app/Livewire/Destination/New/Docker.php +++ b/app/Livewire/Destination/New/Docker.php @@ -72,7 +72,7 @@ class Docker extends Component { try { $isSwarm = $this->selectedServer->isSwarm(); - $this->authorize('create', $isSwarm ? SwarmDocker::class : StandaloneDocker::class); + $this->authorize('update', $this->selectedServer); $this->validate(); if ($isSwarm) { $found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first(); diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php index 2f8592ced2..22f2b8043d 100644 --- a/app/Livewire/Project/Application/DeploymentNavbar.php +++ b/app/Livewire/Project/Application/DeploymentNavbar.php @@ -69,19 +69,22 @@ class DeploymentNavbar extends Component public function copyLogsToClipboard(): string { + $this->authorize('view', $this->application); + $logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR); if (! $logs) { return ''; } - $isMember = auth()->user()->isMember(); + $teamId = $this->application->team()?->id; + $hideDebugLines = is_null($teamId) || ! auth()->user()->isAdminOfTeam($teamId); $markdown = "# Deployment Logs\n\n"; $markdown .= "```\n"; foreach ($logs as $log) { - if ($isMember && ! empty($log['hidden'])) { + if ($hideDebugLines && ! empty($log['hidden'])) { continue; } if (isset($log['output'])) { @@ -101,8 +104,9 @@ class DeploymentNavbar extends Component } catch (\Throwable $e) { return handleError($e, $this); } + $teamId = $this->application->team()->id; $server_id = $this->application_deployment_queue->server_id ?? $this->application->destination->server_id; - $server = Server::ownedByCurrentTeam()->find($server_id); + $server = Server::where('team_id', $teamId)->find($server_id); // First, mark the deployment as cancelled to prevent further processing $this->application_deployment_queue->update([ @@ -110,7 +114,7 @@ class DeploymentNavbar extends Component ]); try { $this->application_deployment_queue->addLogEntry('Deployment cancelled by user.', 'stderr'); - CleanupCancelledDeployment::run($this->application_deployment_queue, currentTeam()->id); + CleanupCancelledDeployment::run($this->application_deployment_queue, $teamId); } catch (\Throwable $e) { // Still mark as cancelled even if cleanup fails return handleError($e, $this); diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 5d272b36fa..9849811dab 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -923,7 +923,7 @@ class General extends Component $this->application->custom_labels = base64_encode($this->customLabels); $this->application->save(); $this->application->refresh(); - $dnsCleanup->queueReleaseOfRemovedHostnames($this->application, $previousDnsHostnames, currentTeam()->id); + $dnsCleanup->queueReleaseOfRemovedHostnames($this->application, $previousDnsHostnames, $this->application->team()->id); $this->syncData(); if ($oldPortsExposes !== $this->portsExposes) { $this->dispatch('applicationNetworkingUpdated')->to(InternalAccess::class); diff --git a/app/Livewire/Project/Application/PreviewDomains.php b/app/Livewire/Project/Application/PreviewDomains.php index 5e9a942a63..b873d4a9ed 100644 --- a/app/Livewire/Project/Application/PreviewDomains.php +++ b/app/Livewire/Project/Application/PreviewDomains.php @@ -503,7 +503,7 @@ class PreviewDomains extends Component 'changed_fields' => $changedFields, ]); } - $dnsCleanup->queueReleaseOfRemovedHostnames($this->preview, $previousDnsHostnames, currentTeam()->id); + $dnsCleanup->queueReleaseOfRemovedHostnames($this->preview, $previousDnsHostnames, $this->preview->application->team()->id); $this->persistDnsStatuses(); $this->refreshDomains(); $this->dispatch('update_links'); diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index 60a7955738..5b6ad61eae 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -89,14 +89,14 @@ class Source extends Component private function getPrivateKeys() { - $this->privateKeys = PrivateKey::whereTeamId(currentTeam()->id)->get()->reject(function ($key) { + $this->privateKeys = PrivateKey::where('team_id', $this->application->team()->id)->get()->reject(function ($key) { return $key->id == $this->privateKeyId; }); } private function getSources() { - $this->sources = currentTeam()->sources()->filter(function ($source) { + $this->sources = $this->application->team()->sources()->filter(function ($source) { if ($source->id === $this->application->source_id && $source->getMorphClass() === $this->application->source_type) { return false; @@ -116,7 +116,7 @@ class Source extends Component { try { $this->authorize('update', $this->application); - $key = PrivateKey::ownedByCurrentTeam()->findOrFail($privateKeyId); + $key = PrivateKey::where('team_id', $this->application->team()->id)->findOrFail($privateKeyId); $this->privateKeyId = $key->id; $this->syncData(true); $this->getPrivateKeys(); @@ -151,7 +151,10 @@ class Source extends Component $this->authorize('update', $this->application); $allowedSourceTypes = [GithubApp::class, GitlabApp::class]; abort_unless(in_array($sourceType, $allowedSourceTypes, true), 404); - $source = $sourceType::ownedByCurrentTeam()->findOrFail($sourceId); + $teamId = $this->application->team()->id; + $source = $sourceType::query() + ->where(fn ($query) => $query->where('team_id', $teamId)->orWhere('is_system_wide', true)) + ->findOrFail($sourceId); $this->application->update([ 'source_id' => $source->id, 'source_type' => $sourceType, diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index cc91f865c9..8f2ff9af40 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -10,8 +10,11 @@ use App\Jobs\VolumeCloneJob; use App\Models\Environment; use App\Models\Project; use App\Models\Server; +use App\Models\StandaloneDocker; +use App\Models\SwarmDocker; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\DB; use Livewire\Component; class CloneMe extends Component @@ -59,8 +62,8 @@ class CloneMe extends Component $this->project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail(); $this->environment = $this->project->environments->where('uuid', $this->environment_uuid)->first(); $this->project_id = $this->project->id; - $this->servers = currentTeam() - ->servers() + $this->servers = Server::query() + ->where('team_id', $this->project->team_id) ->get() ->reject(fn ($server) => $server->isBuildServer()); $this->newName = str($this->project->name.'-clone-'.new_public_id())->slug(); @@ -93,12 +96,16 @@ class CloneMe extends Component public function clone(string $type) { try { - $this->authorize('create', Project::class); + $this->authorize('update', $this->project); $this->validate([ 'selectedDestination' => 'required', 'newName' => ValidationPatterns::nameRules(), ]); - $selectedDestination = find_resource_destination_for_current_team($this->selectedDestination); + $teamId = $this->project->team_id; + if (! $this->environment || (int) $this->environment->project_id !== (int) $this->project->id) { + throw new \Exception('Environment not found.'); + } + $selectedDestination = $this->findProjectTeamDestination($this->selectedDestination); if (! $selectedDestination) { throw new \Exception('Destination not found.'); } @@ -110,94 +117,247 @@ class CloneMe extends Component 'new_name' => $this->newName, 'destination_uuid' => $selectedDestination->uuid, ]); - if ($type === 'project') { - $foundProject = Project::where('name', $this->newName)->first(); - if ($foundProject) { - throw new \Exception('Project with the same name already exists.'); - } - $project = Project::create([ - 'name' => $this->newName, - 'team_id' => currentTeam()->id, - 'description' => $this->project->description.' (clone)', - ]); - if ($this->environment->name !== 'production') { - $project->environments()->create([ - 'name' => $this->environment->name, - 'uuid' => new_public_id(), - ]); - } - $environment = $project->environments->where('name', $this->environment->name)->first(); - } else { - $foundEnv = $this->project->environments()->where('name', $this->newName)->first(); - if ($foundEnv) { - throw new \Exception('Environment with the same name already exists.'); - } - $project = $this->project; - $environment = $this->project->environments()->create([ - 'name' => $this->newName, + [$project, $environment] = DB::transaction(fn () => $this->cloneInto($type, $teamId, $selectedDestination)); + } catch (\Exception $e) { + handleError($e, $this); + + return; + } + + return redirect()->route('project.resource.index', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + ]); + } + + /** + * @return array{0: Project, 1: Environment} + */ + private function cloneInto(string $type, int $teamId, StandaloneDocker|SwarmDocker $selectedDestination): array + { + if ($type === 'project') { + $foundProject = Project::query()->where('team_id', $teamId)->where('name', $this->newName)->first(); + if ($foundProject) { + throw new \Exception('Project with the same name already exists.'); + } + $project = Project::create([ + 'name' => $this->newName, + 'team_id' => $teamId, + 'description' => $this->project->description.' (clone)', + ]); + if ($this->environment->name !== 'production') { + $project->environments()->create([ + 'name' => $this->environment->name, 'uuid' => new_public_id(), ]); } - $applications = $this->environment->applications; - $databases = $this->environment->databases(); - $services = $this->environment->services; - foreach ($applications as $application) { - clone_application($application, $selectedDestination, [ - 'environment_id' => $environment->id, - ], $this->cloneVolumeData); + $environment = $project->environments->where('name', $this->environment->name)->first(); + } else { + $foundEnv = $this->project->environments()->where('name', $this->newName)->first(); + if ($foundEnv) { + throw new \Exception('Environment with the same name already exists.'); + } + $project = $this->project; + $environment = $this->project->environments()->create([ + 'name' => $this->newName, + 'uuid' => new_public_id(), + ]); + } + $applications = $this->environment->applications; + $databases = $this->environment->databases(); + $services = $this->environment->services; + foreach ($applications as $application) { + clone_application($application, $selectedDestination, [ + 'environment_id' => $environment->id, + ], $this->cloneVolumeData); + } + + foreach ($databases as $database) { + $uuid = new_public_id(); + $newDatabase = $database->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => $uuid, + 'status' => 'exited', + 'started_at' => null, + 'environment_id' => $environment->id, + 'destination_id' => $selectedDestination->id, + 'destination_type' => $selectedDestination->getMorphClass(), + ]); + $newDatabase->save(); + + $tags = $database->tags; + foreach ($tags as $tag) { + $newDatabase->tags()->attach($tag->id); } - foreach ($databases as $database) { + $newDatabase->persistentStorages()->delete(); + $persistentVolumes = $database->persistentStorages()->get(); + foreach ($persistentVolumes as $volume) { + $originalName = $volume->name; + $newName = ''; + + if (str_starts_with($originalName, 'postgres-data-')) { + $newName = 'postgres-data-'.$newDatabase->uuid; + } elseif (str_starts_with($originalName, 'mysql-data-')) { + $newName = 'mysql-data-'.$newDatabase->uuid; + } elseif (str_starts_with($originalName, 'redis-data-')) { + $newName = 'redis-data-'.$newDatabase->uuid; + } elseif (str_starts_with($originalName, 'clickhouse-data-')) { + $newName = 'clickhouse-data-'.$newDatabase->uuid; + } elseif (str_starts_with($originalName, 'mariadb-data-')) { + $newName = 'mariadb-data-'.$newDatabase->uuid; + } elseif (str_starts_with($originalName, 'mongodb-data-')) { + $newName = 'mongodb-data-'.$newDatabase->uuid; + } elseif (str_starts_with($originalName, 'keydb-data-')) { + $newName = 'keydb-data-'.$newDatabase->uuid; + } elseif (str_starts_with($originalName, 'dragonfly-data-')) { + $newName = 'dragonfly-data-'.$newDatabase->uuid; + } elseif (str_starts_with($originalName, 'sqlite-data-')) { + $newName = 'sqlite-data-'.$newDatabase->uuid; + } else { + if (str_starts_with($volume->name, $database->uuid)) { + $newName = str($volume->name)->replace($database->uuid, $newDatabase->uuid); + } else { + $newName = $newDatabase->uuid.'-'.$volume->name; + } + } + + $newPersistentVolume = $volume->replicate([ + 'id', + 'created_at', + 'updated_at', + 'uuid', + ])->fill([ + 'name' => $newName, + 'resource_id' => $newDatabase->id, + ]); + $newPersistentVolume->save(); + + if ($this->cloneVolumeData) { + try { + StopDatabase::dispatch($database); + $sourceVolume = $volume->name; + $targetVolume = $newPersistentVolume->name; + $sourceServer = $database->destination->server; + $targetServer = $newDatabase->destination->server; + + VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); + + StartDatabase::dispatch($database); + } catch (\Exception $e) { + \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); + } + } + } + + $fileStorages = $database->fileStorages()->get(); + foreach ($fileStorages as $storage) { + $newStorage = $storage->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'resource_id' => $newDatabase->id, + ]); + $newStorage->save(); + } + + $scheduledBackups = $database->scheduledBackups()->get(); + foreach ($scheduledBackups as $backup) { $uuid = new_public_id(); - $newDatabase = $database->replicate([ + $newBackup = $backup->replicate([ 'id', 'created_at', 'updated_at', ])->fill([ 'uuid' => $uuid, - 'status' => 'exited', - 'started_at' => null, - 'environment_id' => $environment->id, - 'destination_id' => $selectedDestination->id, - 'destination_type' => $selectedDestination->getMorphClass(), + 'database_id' => $newDatabase->id, + 'database_type' => $newDatabase->getMorphClass(), + 'team_id' => $teamId, ]); - $newDatabase->save(); + $newBackup->save(); + } - $tags = $database->tags; - foreach ($tags as $tag) { - $newDatabase->tags()->attach($tag->id); - } + $environmentVaribles = $database->environment_variables()->get(); + foreach ($environmentVaribles as $environmentVarible) { + $payload = []; + $payload['resourceable_id'] = $newDatabase->id; + $payload['resourceable_type'] = $newDatabase->getMorphClass(); + $newEnvironmentVariable = $environmentVarible->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill($payload); + $newEnvironmentVariable->save(); + } - $newDatabase->persistentStorages()->delete(); - $persistentVolumes = $database->persistentStorages()->get(); + $database->cloneSecretManagerLinkTo($newDatabase); + } + + foreach ($services as $service) { + $uuid = new_public_id(); + $newService = $service->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => $uuid, + 'environment_id' => $environment->id, + 'destination_id' => $selectedDestination->id, + 'destination_type' => $selectedDestination->getMorphClass(), + 'server_id' => $selectedDestination->server_id, + ]); + $newService->save(); + + $tags = $service->tags; + foreach ($tags as $tag) { + $newService->tags()->attach($tag->id); + } + + $scheduledTasks = $service->scheduled_tasks()->get(); + foreach ($scheduledTasks as $task) { + $newTask = $task->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => new_public_id(), + 'service_id' => $newService->id, + 'team_id' => $teamId, + ]); + $newTask->save(); + } + + $environmentVariables = $service->environment_variables()->get(); + foreach ($environmentVariables as $environmentVariable) { + $newEnvironmentVariable = $environmentVariable->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'resourceable_id' => $newService->id, + 'resourceable_type' => $newService->getMorphClass(), + ]); + $newEnvironmentVariable->save(); + } + + $service->cloneSecretManagerLinkTo($newService); + + foreach ($newService->applications() as $application) { + $application->fill([ + 'status' => 'exited', + ])->save(); + + $persistentVolumes = $application->persistentStorages()->get(); foreach ($persistentVolumes as $volume) { - $originalName = $volume->name; $newName = ''; - - if (str_starts_with($originalName, 'postgres-data-')) { - $newName = 'postgres-data-'.$newDatabase->uuid; - } elseif (str_starts_with($originalName, 'mysql-data-')) { - $newName = 'mysql-data-'.$newDatabase->uuid; - } elseif (str_starts_with($originalName, 'redis-data-')) { - $newName = 'redis-data-'.$newDatabase->uuid; - } elseif (str_starts_with($originalName, 'clickhouse-data-')) { - $newName = 'clickhouse-data-'.$newDatabase->uuid; - } elseif (str_starts_with($originalName, 'mariadb-data-')) { - $newName = 'mariadb-data-'.$newDatabase->uuid; - } elseif (str_starts_with($originalName, 'mongodb-data-')) { - $newName = 'mongodb-data-'.$newDatabase->uuid; - } elseif (str_starts_with($originalName, 'keydb-data-')) { - $newName = 'keydb-data-'.$newDatabase->uuid; - } elseif (str_starts_with($originalName, 'dragonfly-data-')) { - $newName = 'dragonfly-data-'.$newDatabase->uuid; - } elseif (str_starts_with($originalName, 'sqlite-data-')) { - $newName = 'sqlite-data-'.$newDatabase->uuid; + if (str_starts_with($volume->name, $application->uuid)) { + $newName = str($volume->name)->replace($application->uuid, $application->uuid); } else { - if (str_starts_with($volume->name, $database->uuid)) { - $newName = str($volume->name)->replace($database->uuid, $newDatabase->uuid); - } else { - $newName = $newDatabase->uuid.'-'.$volume->name; - } + $newName = $application->uuid.'-'.$volume->name; } $newPersistentVolume = $volume->replicate([ @@ -207,21 +367,76 @@ class CloneMe extends Component 'uuid', ])->fill([ 'name' => $newName, - 'resource_id' => $newDatabase->id, + 'resource_id' => $application->id, ]); $newPersistentVolume->save(); if ($this->cloneVolumeData) { try { - StopDatabase::dispatch($database); + StopService::dispatch($application); $sourceVolume = $volume->name; $targetVolume = $newPersistentVolume->name; - $sourceServer = $database->destination->server; - $targetServer = $newDatabase->destination->server; + $sourceServer = $application->service->destination->server; + $targetServer = $newService->destination->server; VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - StartDatabase::dispatch($database); + StartService::dispatch($application); + } catch (\Exception $e) { + \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); + } + } + } + + $fileStorages = $application->fileStorages()->get(); + foreach ($fileStorages as $storage) { + $newStorage = $storage->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'resource_id' => $application->id, + ]); + $newStorage->save(); + } + } + + foreach ($newService->databases() as $database) { + $database->fill([ + 'status' => 'exited', + ])->save(); + + $persistentVolumes = $database->persistentStorages()->get(); + foreach ($persistentVolumes as $volume) { + $newName = ''; + if (str_starts_with($volume->name, $database->uuid)) { + $newName = str($volume->name)->replace($database->uuid, $database->uuid); + } else { + $newName = $database->uuid.'-'.$volume->name; + } + + $newPersistentVolume = $volume->replicate([ + 'id', + 'created_at', + 'updated_at', + 'uuid', + ])->fill([ + 'name' => $newName, + 'resource_id' => $database->id, + ]); + $newPersistentVolume->save(); + + if ($this->cloneVolumeData) { + try { + StopService::dispatch($database->service); + $sourceVolume = $volume->name; + $targetVolume = $newPersistentVolume->name; + $sourceServer = $database->service->destination->server; + $targetServer = $newService->destination->server; + + VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); + + StartService::dispatch($database->service); } catch (\Exception $e) { \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); } @@ -235,7 +450,7 @@ class CloneMe extends Component 'created_at', 'updated_at', ])->fill([ - 'resource_id' => $newDatabase->id, + 'resource_id' => $database->id, ]); $newStorage->save(); } @@ -249,218 +464,30 @@ class CloneMe extends Component 'updated_at', ])->fill([ 'uuid' => $uuid, - 'database_id' => $newDatabase->id, - 'database_type' => $newDatabase->getMorphClass(), - 'team_id' => currentTeam()->id, + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), + 'team_id' => $teamId, ]); $newBackup->save(); } - - $environmentVaribles = $database->environment_variables()->get(); - foreach ($environmentVaribles as $environmentVarible) { - $payload = []; - $payload['resourceable_id'] = $newDatabase->id; - $payload['resourceable_type'] = $newDatabase->getMorphClass(); - $newEnvironmentVariable = $environmentVarible->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill($payload); - $newEnvironmentVariable->save(); - } - - $database->cloneSecretManagerLinkTo($newDatabase); } - foreach ($services as $service) { - $uuid = new_public_id(); - $newService = $service->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'uuid' => $uuid, - 'environment_id' => $environment->id, - 'destination_id' => $selectedDestination->id, - 'destination_type' => $selectedDestination->getMorphClass(), - 'server_id' => $selectedDestination->server_id, - ]); - $newService->save(); - - $tags = $service->tags; - foreach ($tags as $tag) { - $newService->tags()->attach($tag->id); - } - - $scheduledTasks = $service->scheduled_tasks()->get(); - foreach ($scheduledTasks as $task) { - $newTask = $task->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'uuid' => new_public_id(), - 'service_id' => $newService->id, - 'team_id' => currentTeam()->id, - ]); - $newTask->save(); - } - - $environmentVariables = $service->environment_variables()->get(); - foreach ($environmentVariables as $environmentVariable) { - $newEnvironmentVariable = $environmentVariable->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resourceable_id' => $newService->id, - 'resourceable_type' => $newService->getMorphClass(), - ]); - $newEnvironmentVariable->save(); - } - - $service->cloneSecretManagerLinkTo($newService); - - foreach ($newService->applications() as $application) { - $application->fill([ - 'status' => 'exited', - ])->save(); - - $persistentVolumes = $application->persistentStorages()->get(); - foreach ($persistentVolumes as $volume) { - $newName = ''; - if (str_starts_with($volume->name, $application->uuid)) { - $newName = str($volume->name)->replace($application->uuid, $application->uuid); - } else { - $newName = $application->uuid.'-'.$volume->name; - } - - $newPersistentVolume = $volume->replicate([ - 'id', - 'created_at', - 'updated_at', - 'uuid', - ])->fill([ - 'name' => $newName, - 'resource_id' => $application->id, - ]); - $newPersistentVolume->save(); - - if ($this->cloneVolumeData) { - try { - StopService::dispatch($application); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $application->service->destination->server; - $targetServer = $newService->destination->server; - - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - - StartService::dispatch($application); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); - } - } - } - - $fileStorages = $application->fileStorages()->get(); - foreach ($fileStorages as $storage) { - $newStorage = $storage->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resource_id' => $application->id, - ]); - $newStorage->save(); - } - } - - foreach ($newService->databases() as $database) { - $database->fill([ - 'status' => 'exited', - ])->save(); - - $persistentVolumes = $database->persistentStorages()->get(); - foreach ($persistentVolumes as $volume) { - $newName = ''; - if (str_starts_with($volume->name, $database->uuid)) { - $newName = str($volume->name)->replace($database->uuid, $database->uuid); - } else { - $newName = $database->uuid.'-'.$volume->name; - } - - $newPersistentVolume = $volume->replicate([ - 'id', - 'created_at', - 'updated_at', - 'uuid', - ])->fill([ - 'name' => $newName, - 'resource_id' => $database->id, - ]); - $newPersistentVolume->save(); - - if ($this->cloneVolumeData) { - try { - StopService::dispatch($database->service); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $database->service->destination->server; - $targetServer = $newService->destination->server; - - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - - StartService::dispatch($database->service); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); - } - } - } - - $fileStorages = $database->fileStorages()->get(); - foreach ($fileStorages as $storage) { - $newStorage = $storage->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resource_id' => $database->id, - ]); - $newStorage->save(); - } - - $scheduledBackups = $database->scheduledBackups()->get(); - foreach ($scheduledBackups as $backup) { - $uuid = new_public_id(); - $newBackup = $backup->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'uuid' => $uuid, - 'database_id' => $database->id, - 'database_type' => $database->getMorphClass(), - 'team_id' => currentTeam()->id, - ]); - $newBackup->save(); - } - } - - $newService->parse(); - } - - } catch (\Exception $e) { - handleError($e, $this); - - return; - } finally { - if (! isset($e)) { - return redirect()->route('project.resource.index', [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - ]); - } + $newService->parse(); } + + return [$project, $environment]; + } + + private function findProjectTeamDestination(?string $uuid): StandaloneDocker|SwarmDocker|null + { + if (blank($uuid)) { + return null; + } + + $teamId = $this->project->team_id; + $destination = StandaloneDocker::query()->whereRelation('server', 'team_id', $teamId)->where('uuid', $uuid)->first() + ?? SwarmDocker::query()->whereRelation('server', 'team_id', $teamId)->where('uuid', $uuid)->first(); + + return $destination?->server?->canHostResources() ? $destination : null; } } diff --git a/app/Livewire/Project/Database/Backup/Execution.php b/app/Livewire/Project/Database/Backup/Execution.php index a96a190e82..726ee3b698 100644 --- a/app/Livewire/Project/Database/Backup/Execution.php +++ b/app/Livewire/Project/Database/Backup/Execution.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Database\Backup; +use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use Livewire\Component; @@ -43,7 +44,10 @@ class Execution extends Component $this->database = $database; $this->backup = $backup; $this->executions = $executions; - $this->s3s = currentTeam()->s3s; + $this->s3s = S3Storage::query() + ->where('team_id', $database->team()->id) + ->where('is_usable', true) + ->get(); $this->parameters = get_route_parameters(); $this->section = match (request()->route()?->getName()) { 'project.database.backup.s3' => 's3', diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 0507529696..59dd1a29c0 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -6,6 +6,7 @@ use App\Jobs\DatabaseBackupJob; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\ServiceDatabase; +use App\Models\StandalonePostgresql; use App\Traits\ListensToTeamChannel; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -400,22 +401,16 @@ class BackupEdit extends Component private function availableS3StorageIds(): Collection { - $storages = collect($this->availableS3Storages); - $storageIds = $storages->pluck('id')->filter()->all(); + $database = $this->backup->database; + // The instance database (id 0) has no project and belongs to the root team. + $teamId = $database instanceof StandalonePostgresql && $database->id === 0 ? 0 : $database?->team()?->id; - if (empty($storageIds)) { - return collect(); - } - - $teamIds = $storages->pluck('team_id')->reject(fn ($teamId) => $teamId === null)->unique()->values()->all(); - - if (empty($teamIds)) { + if ($teamId === null) { return collect(); } return S3Storage::query() - ->whereKey($storageIds) - ->whereIn('team_id', $teamIds) + ->where('team_id', $teamId) ->where('is_usable', true) ->pluck('id'); } diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index d61a19f2c7..ea8d8f197b 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -10,6 +10,7 @@ use App\Support\ValidationPatterns; use App\Traits\ListensToTeamChannel; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Locked; use Livewire\Component; class General extends Component @@ -45,6 +46,10 @@ class General extends Component public bool $isPasswordHiddenForMember = false; + /** Unnamed Docker volume that holds the current data; shown as a warning. */ + #[Locked] + public ?string $anonymousDataVolume = null; + public function getListeners(): array { return $this->teamChannelListeners([ @@ -63,6 +68,7 @@ class General extends Component return; } + $this->anonymousDataVolume = $this->database->anonymousDataVolume(); } catch (\Throwable $e) { return handleError($e, $this); } @@ -197,6 +203,27 @@ class General extends Component } } + public function keepCurrentDataVolume(): void + { + try { + $this->authorize('update', $this->database); + + $volumeName = $this->database->anonymousDataVolume(); + if ($volumeName === null) { + $this->anonymousDataVolume = null; + $this->dispatch('info', 'This database already uses its data volume.'); + + return; + } + + $this->database->keepAnonymousDataVolume($volumeName); + $this->anonymousDataVolume = null; + $this->dispatch('success', 'The current data volume is now the data volume of this database. You can restart it safely.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function databaseProxyStopped(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Service/BackupExecutions.php b/app/Livewire/Project/Service/BackupExecutions.php index 2f21e60383..81be0ff8ad 100644 --- a/app/Livewire/Project/Service/BackupExecutions.php +++ b/app/Livewire/Project/Service/BackupExecutions.php @@ -122,12 +122,14 @@ class BackupExecutions extends Component ->whereIn('id', $rows->where('type', 'storage')->pluck('id')) ->get()->keyBy('id'); - return $rows->map(function (object $row) use ($databaseExecutions, $volumeExecutions): array { + $serviceTeamId = $this->service->team()?->id; + + return $rows->map(function (object $row) use ($databaseExecutions, $volumeExecutions, $serviceTeamId): array { $isDatabase = $row->type === 'database'; $execution = $isDatabase ? $databaseExecutions->get($row->id) : $volumeExecutions->get($row->id); $schedule = $isDatabase ? $execution->scheduledDatabaseBackup : $execution->scheduledVolumeBackup; $storage = $isDatabase ? ($schedule->save_s3 ? $schedule->s3 : null) : $execution->s3; - if ($storage?->team_id !== currentTeam()->id) { + if ($storage?->team_id !== $serviceTeamId) { $storage = null; } $storageLabel = $storage ? $storage->name.' (bucket: '.$storage->bucket.')' : 'Unavailable'; diff --git a/app/Livewire/Project/Service/DatabaseBackups.php b/app/Livewire/Project/Service/DatabaseBackups.php index 8535584dd8..71885094aa 100644 --- a/app/Livewire/Project/Service/DatabaseBackups.php +++ b/app/Livewire/Project/Service/DatabaseBackups.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Service; +use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\Service; use App\Models\ServiceDatabase; @@ -77,7 +78,10 @@ class DatabaseBackups extends Component $this->backup = $this->serviceDatabase->scheduledBackups() ->where('uuid', request()->route('backup_uuid')) ->firstOrFail(); - $this->s3s = currentTeam()->s3s; + $this->s3s = S3Storage::query() + ->where('team_id', $this->service->team()->id) + ->where('is_usable', true) + ->get(); $this->backupParameters = [...$this->parameters, 'backup_uuid' => $this->backup->uuid]; $this->section = match (request()->route()?->getName()) { 'project.service.database.backup.s3' => 's3', diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index e486f8c07a..fd3b8c54da 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -5,6 +5,7 @@ namespace App\Livewire\Project\Service; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; use App\Actions\Service\DeleteService; +use App\Models\S3Storage; use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; @@ -128,7 +129,7 @@ class Index extends Component $this->serviceApplication = null; $this->resourceType = 'database'; $this->initializeDatabaseProperties(); - $this->s3s = currentTeam()->s3s; + $this->s3s = $this->serviceTeamS3Storages(); return; } @@ -146,7 +147,7 @@ class Index extends Component $this->serviceApplication = $serviceApplication; $this->resourceType = 'application'; $this->initializeApplicationProperties(); - $this->s3s = currentTeam()->s3s; + $this->s3s = $this->serviceTeamS3Storages(); return; } @@ -189,12 +190,20 @@ class Index extends Component $this->serviceDatabase->getFilesFromServer(); $this->initializeDatabaseProperties(); } - $this->s3s = currentTeam()->s3s; + $this->s3s = $this->serviceTeamS3Storages(); } catch (\Throwable $e) { return handleError($e, $this); } } + private function serviceTeamS3Storages(): Collection + { + return S3Storage::query() + ->where('team_id', $this->service->team()->id) + ->where('is_usable', true) + ->get(); + } + private function initializeDatabaseProperties(): void { $this->server = $this->serviceDatabase->service->destination->server; @@ -661,7 +670,7 @@ class Index extends Component $this->serviceApplication->save(); $this->auditServiceSubResourceUpdate($this->serviceApplication, $changedFields); $this->serviceApplication->refresh(); - app(ManagedDnsRecordCleanup::class)->queueReleaseOfRemovedHostnames($this->serviceApplication, $previousDnsHostnames, currentTeam()->id); + app(ManagedDnsRecordCleanup::class)->queueReleaseOfRemovedHostnames($this->serviceApplication, $previousDnsHostnames, $this->service->team()->id); $this->syncApplicationData(false); updateCompose($this->serviceApplication); if (str($this->serviceApplication->fqdn)->contains(',')) { diff --git a/app/Livewire/Project/Service/VolumeBackup/Index.php b/app/Livewire/Project/Service/VolumeBackup/Index.php index 77ee2b9d1c..60a78e5c15 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Index.php +++ b/app/Livewire/Project/Service/VolumeBackup/Index.php @@ -4,6 +4,7 @@ namespace App\Livewire\Project\Service\VolumeBackup; use App\Jobs\DatabaseBackupJob; use App\Jobs\VolumeBackupJob; +use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledVolumeBackup; use App\Models\Service; @@ -65,7 +66,10 @@ class Index extends Component { $this->authorize('update', $this->service); $this->loadSelectedSchedule($backupUuid); - $this->s3s = currentTeam()->s3s; + $this->s3s = S3Storage::query() + ->where('team_id', $this->service->team()?->id) + ->where('is_usable', true) + ->get(); $this->scheduleModalOpen = true; } @@ -132,6 +136,7 @@ class Index extends Component 'backups' => $backups, 'databaseBackups' => $databaseBackups, 'databaseTargets' => $databaseTargets, + 'serviceTeamId' => $this->service->team()?->id, ]); } diff --git a/app/Livewire/Project/Shared/ConfigurationChecker.php b/app/Livewire/Project/Shared/ConfigurationChecker.php index 14ce65f95f..1b78f65ecd 100644 --- a/app/Livewire/Project/Shared/ConfigurationChecker.php +++ b/app/Livewire/Project/Shared/ConfigurationChecker.php @@ -107,7 +107,8 @@ class ConfigurationChecker extends Component $array = $diff->toArray(); // Fail closed: only owners/admins may see unlocked env values. - $redactEnvironment = ! (bool) auth()->user()?->isAdmin(); + $teamId = $this->resource->team()?->id; + $redactEnvironment = is_null($teamId) || ! (bool) auth()->user()?->isAdminOfTeam($teamId); $array['changes'] = $this->redactHiddenChanges($array['changes'] ?? [], $redactEnvironment); $this->configurationDiff = $array; diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 1e5a6fb2d4..570973e64d 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -9,6 +9,7 @@ use App\Models\Application; use App\Models\Server; use App\Models\StandaloneDocker; use App\Traits\ListensToTeamChannel; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; @@ -45,9 +46,16 @@ class Destination extends Component $all_networks = $all_networks->push($this->resource->destination); $all_networks = $all_networks->merge($this->resource->additional_networks); - $this->networks = Server::isUsable()->get()->map(function ($server) { - return $server->standaloneDockers; - })->flatten(); + $this->networks = $this->resourceServers() + ->whereRelation('settings', 'is_reachable', true) + ->whereRelation('settings', 'is_usable', true) + ->whereRelation('settings', 'is_swarm_worker', false) + ->whereRelation('settings', 'force_disabled', false) + ->get() + ->filter(fn (Server $server) => $server->canHostResources()) + ->map(function ($server) { + return $server->standaloneDockers; + })->flatten(); $this->networks = $this->networks->reject(function ($network) use ($all_networks) { return $all_networks->pluck('id')->contains($network->id); }); @@ -61,11 +69,19 @@ class Destination extends Component } } + private function resourceServers(): Builder + { + return Server::query() + ->where('team_id', $this->resource->team()->id) + ->with('settings', 'standaloneDockers') + ->orderBy('name'); + } + public function stop($serverId) { try { $this->authorize('deploy', $this->resource); - $server = Server::ownedByCurrentTeam()->findOrFail($serverId); + $server = $this->resourceServers()->findOrFail($serverId); StopApplicationOneServer::run($this->resource, $server); auditLog('ui.application.destination_stopped', [ 'team_id' => $this->resource->team()?->id, @@ -90,7 +106,7 @@ class Destination extends Component return; } $deployment_uuid = new_public_id(); - $server = Server::ownedByCurrentTeam()->findOrFail($server_id); + $server = $this->resourceServers()->findOrFail($server_id); $destination = $server->standaloneDockers->where('id', $network_id)->firstOrFail(); $result = queue_application_deployment( deployment_uuid: $deployment_uuid, @@ -125,8 +141,8 @@ class Destination extends Component public function promote(int $network_id, int $server_id) { try { - $server = Server::ownedByCurrentTeam()->findOrFail($server_id); - $network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id); + $server = $this->resourceServers()->findOrFail($server_id); + $network = StandaloneDocker::query()->where('server_id', $server->id)->findOrFail($network_id); $this->authorize('update', $this->resource); $this->resource->getConnection()->transaction(function () use ($network, $server) { $mainDestination = $this->resource->destination; @@ -156,8 +172,8 @@ class Destination extends Component public function addServer(int $network_id, int $server_id) { try { - $server = Server::ownedByCurrentTeam()->findOrFail($server_id); - $network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id); + $server = $this->resourceServers()->findOrFail($server_id); + $network = StandaloneDocker::query()->where('server_id', $server->id)->findOrFail($network_id); $this->authorize('update', $this->resource); $reason = $this->resource instanceof Application ? $this->resource->additionalServersUnavailableReason($server) @@ -190,7 +206,7 @@ class Destination extends Component return; } - $server = Server::ownedByCurrentTeam()->findOrFail($server_id); + $server = $this->resourceServers()->findOrFail($server_id); StopApplicationOneServer::run($this->resource, $server); $this->resource->additional_networks() ->wherePivot('server_id', $server_id) diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index 15b4410a5f..8adba953db 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -92,7 +92,7 @@ class Add extends Component #[Computed] public function availableSharedVariables(): array { - $team = currentTeam(); + $team = $this->resource ? $this->resource->team() : currentTeam(); $result = [ 'team' => [], 'project' => [], diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 32a8e7e28a..55da7f7228 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -219,12 +219,17 @@ class All extends Component return $this->environmentVariableRowCount > 0; } + private function canViewEnvironmentValues(): bool + { + return auth()->user()?->can('manageEnvironment', $this->resource) ?? false; + } + private function nullLockedValues($envs) { - $isMember = auth()->user()?->isMember(); + $hideValues = ! $this->canViewEnvironmentValues(); - $envs->each(function ($env) use ($isMember) { - if ($env->is_shown_once || $isMember) { + $envs->each(function ($env) use ($hideValues) { + if ($env->is_shown_once || $hideValues) { $env->value = null; $env->real_value = null; } @@ -828,12 +833,12 @@ class All extends Component private function formatEnvironmentVariables($variables) { - $isMember = auth()->user()?->isMember(); + $hideValues = ! $this->canViewEnvironmentValues(); return $variables ->reject(fn ($item): bool => $this->isProtectedEnvironmentVariable($item->key)) - ->map(function ($item) use ($isMember) { - if ($isMember) { + ->map(function ($item) use ($hideValues) { + if ($hideValues) { return "$item->key=(Hidden, only admins can view)"; } if ($item->is_shown_once) { @@ -849,6 +854,7 @@ class All extends Component public function switch() { + $this->authorize('view', $this->resource); $this->view = $this->view === 'normal' ? 'dev' : 'normal'; if ($this->view === 'dev') { $this->ensureEnvironmentVariablesLoaded(); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 191c7b8223..636a0e6ea8 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -173,7 +173,7 @@ class Show extends Component public function copyValue(): ?string { - if ($this->env->is_shown_once || (auth()->user()?->cannot('update', $this->env) ?? true)) { + if ($this->env->is_shown_once || $this->valuesHiddenForUser()) { return null; } @@ -231,7 +231,7 @@ class Show extends Component $this->is_required = (bool) ($this->env->is_required ?? false); // Use the stored column, not the value-based accessor (that decrypts). $this->is_shared = (bool) ($this->env->getAttributes()['is_shared'] ?? false); - $this->isValueHidden = auth()->user()?->isMember() ?? true; + $this->isValueHidden = $this->valuesHiddenForUser(); if ($this->valuesLoaded) { $this->hydrateValueFields(); @@ -260,12 +260,17 @@ class Show extends Component $this->is_really_required = $this->is_required && blank($this->value); } - if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) { + if ($this->env->is_shown_once || $this->valuesHiddenForUser()) { $this->value = null; $this->real_value = null; } - $this->isValueHidden = auth()->user()?->isMember() ?? true; + $this->isValueHidden = $this->valuesHiddenForUser(); + } + + private function valuesHiddenForUser(): bool + { + return auth()->user()?->cannot('update', $this->env) ?? true; } public function checkEnvs() @@ -345,7 +350,7 @@ class Show extends Component // Shared across all Show row components in the same request (edit modals). static $requestCache = []; - $team = currentTeam(); + $team = $this->isSharedVariable ? $this->env->team : $this->env->resourceable?->team(); $cacheKey = implode('|', [ $team?->id ?? 'none', data_get($this->parameters, 'project_uuid', ''), diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 6f9bc0265e..2b6f5b4587 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -11,7 +11,9 @@ use App\Jobs\VolumeCloneJob; use App\Models\Application; use App\Models\Environment; use App\Models\Project; +use App\Models\Server; use App\Models\StandaloneClickhouse; +use App\Models\StandaloneDocker; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; use App\Models\StandaloneMariadb; @@ -20,9 +22,11 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Models\StandaloneSqlite; +use App\Models\SwarmDocker; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Validation\ValidationException; use Livewire\Component; +use RuntimeException; class ResourceOperations extends Component { @@ -49,8 +53,9 @@ class ResourceOperations extends Component $parameters = get_route_parameters(); $this->projectUuid = data_get($parameters, 'project_uuid'); $this->environmentUuid = data_get($parameters, 'environment_uuid'); - $this->projects = Project::ownedByCurrentTeamCached(); - $servers = currentTeam()->servers()->get(); + $teamId = $this->resourceTeamId(); + $this->projects = Project::query()->where('team_id', $teamId)->orderByRaw('LOWER(name)')->get(); + $servers = Server::query()->where('team_id', $teamId)->get(); $this->servers = $servers->reject(fn ($server) => $server->isBuildServer()); $this->buildServers = $servers->filter(fn ($server) => $server->isBuildServer()); } @@ -72,13 +77,13 @@ class ResourceOperations extends Component $new_environment = $this->resource->environment; if ($environment_id !== null) { - $new_environment = Environment::ownedByCurrentTeam()->find($environment_id); + $new_environment = $this->findResourceTeamEnvironment($environment_id); if (! $new_environment) { return $this->addError('environment_id', 'Environment not found.'); } } - $new_destination = find_resource_destination_for_current_team($destination_uuid); + $new_destination = $this->findResourceTeamDestination($destination_uuid); if (! $new_destination) { return $this->addError('destination_id', 'Destination not found.'); } @@ -224,7 +229,7 @@ class ResourceOperations extends Component 'uuid' => $uuid, 'database_id' => $new_resource->id, 'database_type' => $new_resource->getMorphClass(), - 'team_id' => currentTeam()->id, + 'team_id' => $this->resourceTeamId(), ]); $newBackup->save(); } @@ -283,7 +288,7 @@ class ResourceOperations extends Component ])->fill([ 'uuid' => new_public_id(), 'service_id' => $new_resource->id, - 'team_id' => currentTeam()->id, + 'team_id' => $this->resourceTeamId(), ]); $newTask->save(); } @@ -408,9 +413,12 @@ class ResourceOperations extends Component { try { $this->authorize('update', $this->resource); - $new_environment = Environment::ownedByCurrentTeam()->findOrFail($environment_id); + $new_environment = $this->findResourceTeamEnvironment($environment_id); + if (! $new_environment) { + return $this->addError('environment_id', 'Environment not found.'); + } $this->resource->fill([ - 'environment_id' => $environment_id, + 'environment_id' => $new_environment->id, ])->save(); if ($this->resource->type() === 'application') { $route = route('project.application.configuration', [ @@ -447,7 +455,7 @@ class ResourceOperations extends Component try { $this->authorize('update', $this->resource); - $new_destination = find_resource_destination_for_current_team($destination_uuid); + $new_destination = $this->findResourceTeamDestination($destination_uuid); if (! $new_destination) { return $this->addError('destination_id', 'Destination not found.'); } @@ -500,6 +508,32 @@ class ResourceOperations extends Component } } + private function resourceTeamId(): int + { + return $this->resource->team()?->id ?? throw new RuntimeException('The resource has no team.'); + } + + /** Cloning and moving stay inside the resource team; the session team can differ after a team switch. */ + private function findResourceTeamEnvironment(int|string|null $environmentId): ?Environment + { + return Environment::query() + ->whereRelation('project', 'team_id', $this->resourceTeamId()) + ->find($environmentId); + } + + private function findResourceTeamDestination(?string $uuid): StandaloneDocker|SwarmDocker|null + { + if (blank($uuid)) { + return null; + } + + $teamId = $this->resourceTeamId(); + $destination = StandaloneDocker::query()->whereRelation('server', 'team_id', $teamId)->where('uuid', $uuid)->first() + ?? SwarmDocker::query()->whereRelation('server', 'team_id', $teamId)->where('uuid', $uuid)->first(); + + return $destination?->server?->canHostResources() ? $destination : null; + } + public function render() { return view('livewire.project.shared.resource-operations'); diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php index 525a3ccb41..df49f6cf0e 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Add.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -111,7 +111,7 @@ class Add extends Component $task->frequency = $this->frequency; $task->container = $this->container; $task->timeout = $this->timeout; - $task->team_id = currentTeam()->id; + $task->team_id = $this->resource->team()->id; switch ($this->type) { case 'application': diff --git a/app/Livewire/Project/Shared/Tags.php b/app/Livewire/Project/Shared/Tags.php index 6b3493ace9..9305d20a63 100644 --- a/app/Livewire/Project/Shared/Tags.php +++ b/app/Livewire/Project/Shared/Tags.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Shared; use App\Models\Tag; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; @@ -28,7 +29,7 @@ class Tags extends Component public function loadTags() { - $this->tags = Tag::ownedByCurrentTeam()->get(); + $this->tags = $this->resourceTags()->get(); $this->filteredTags = $this->tags->filter(function ($tag) { return ! $this->resource->tags->contains($tag); }); @@ -52,13 +53,11 @@ class Tags extends Component continue; } - $found = Tag::ownedByCurrentTeam()->where(['name' => $tag])->exists(); - if (! $found) { - $found = Tag::create([ + $found = $this->resourceTags()->where('name', $tag)->first() + ?? Tag::create([ 'name' => $tag, - 'team_id' => currentTeam()->id, + 'team_id' => $this->resource->team()->id, ]); - } $this->resource->tags()->attach($found->id); } $this->refresh(); @@ -71,7 +70,7 @@ class Tags extends Component { try { $this->authorize('update', $this->resource); - $tag = Tag::ownedByCurrentTeam()->findOrFail($id); + $tag = $this->resourceTags()->findOrFail($id); if ($this->resource->tags()->whereKey($tag->id)->exists()) { $this->dispatch('error', 'Duplicate tags.', 'Tag '.e($tag->name).' already added.'); @@ -90,7 +89,7 @@ class Tags extends Component try { $this->authorize('update', $this->resource); $this->resource->tags()->detach($id); - $found_more_tags = Tag::ownedByCurrentTeam()->find($id); + $found_more_tags = $this->resourceTags()->find($id); $found_more_tags?->deleteIfOrphaned(); $this->refresh(); $this->dispatch('success', 'Tag deleted.'); @@ -99,6 +98,11 @@ class Tags extends Component } } + private function resourceTags(): Builder + { + return Tag::query()->where('team_id', $this->resource->team()?->id)->orderBy('name'); + } + public function refresh() { $this->resource->refresh(); // Remove this when legacy_model_binding is false diff --git a/app/Livewire/Project/Show.php b/app/Livewire/Project/Show.php index 0481a3f4d5..ee73e72f93 100644 --- a/app/Livewire/Project/Show.php +++ b/app/Livewire/Project/Show.php @@ -63,7 +63,7 @@ class Show extends Component public function submit() { try { - $this->authorize('create', Environment::class); + $this->authorize('update', $this->project); $this->validate(); $environment = Environment::create([ 'name' => $this->name, diff --git a/app/Livewire/Server/CloudProviderToken/Show.php b/app/Livewire/Server/CloudProviderToken/Show.php index a42a688042..e8216d0549 100644 --- a/app/Livewire/Server/CloudProviderToken/Show.php +++ b/app/Livewire/Server/CloudProviderToken/Show.php @@ -44,7 +44,7 @@ class Show extends Component $this->provider = $this->server->vultr_instance_id ? 'vultr' : 'hetzner'; $this->providerName = $this->provider === 'vultr' ? 'Vultr' : 'Hetzner'; - $this->cloudProviderTokens = CloudProviderToken::ownedByCurrentTeam() + $this->cloudProviderTokens = CloudProviderToken::where('team_id', $this->server->team_id) ->where('provider', $this->provider) ->get(); } @@ -56,7 +56,7 @@ class Show extends Component public function setCloudProviderToken($tokenId) { - $ownedToken = CloudProviderToken::ownedByCurrentTeam()->find($tokenId); + $ownedToken = CloudProviderToken::where('team_id', $this->server->team_id)->find($tokenId); if (is_null($ownedToken)) { $this->dispatch('error', 'You are not allowed to use this token.'); @@ -77,7 +77,7 @@ class Show extends Component $this->server->save(); auditLog('ui.server.cloud_token_assigned', [ - 'team_id' => currentTeam()->id, + 'team_id' => $this->server->team_id, 'server_uuid' => $this->server->uuid, 'server_name' => $this->server->name, 'cloud_token_uuid' => $ownedToken->uuid, @@ -171,7 +171,7 @@ class Show extends Component } auditLog('ui.server.cloud_token_validated', [ - 'team_id' => currentTeam()->id, + 'team_id' => $this->server->team_id, 'server_uuid' => $this->server->uuid, 'server_name' => $this->server->name, 'cloud_token_uuid' => $token->uuid, diff --git a/app/Livewire/Server/Destinations.php b/app/Livewire/Server/Destinations.php index c3740a7613..7f6cfe954e 100644 --- a/app/Livewire/Server/Destinations.php +++ b/app/Livewire/Server/Destinations.php @@ -29,8 +29,8 @@ class Destinations extends Component public function add($name) { + $this->authorize('update', $this->server); if ($this->server->isSwarm()) { - $this->authorize('create', SwarmDocker::class); $found = $this->server->swarmDockers()->where('network', $name)->first(); if ($found) { $this->dispatch('error', 'Network already added to this server.'); @@ -45,7 +45,6 @@ class Destinations extends Component $this->auditDestinationCreated($destination, 'swarm'); } } else { - $this->authorize('create', StandaloneDocker::class); $found = $this->server->standaloneDockers()->where('network', $name)->first(); if ($found) { $this->dispatch('error', 'Network already added to this server.'); diff --git a/app/Livewire/Server/PrivateKey/Show.php b/app/Livewire/Server/PrivateKey/Show.php index 54f3436dce..7bc02aa03d 100644 --- a/app/Livewire/Server/PrivateKey/Show.php +++ b/app/Livewire/Server/PrivateKey/Show.php @@ -22,15 +22,20 @@ class Show extends Component { try { $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); - $this->privateKeys = PrivateKey::ownedByCurrentTeam()->get()->where('is_git_related', false); + $this->loadPrivateKeys(); } catch (\Throwable $e) { return handleError($e, $this); } } + private function loadPrivateKeys(): void + { + $this->privateKeys = PrivateKey::where('team_id', $this->server->team_id)->get()->where('is_git_related', false); + } + public function setPrivateKey($privateKeyId) { - $ownedPrivateKey = PrivateKey::ownedByCurrentTeam()->find($privateKeyId); + $ownedPrivateKey = PrivateKey::where('team_id', $this->server->team_id)->find($privateKeyId); if (is_null($ownedPrivateKey)) { $this->dispatch('error', 'You are not allowed to use this private key.'); @@ -59,6 +64,7 @@ class Show extends Component { try { $this->authorize('create', PrivateKey::class); + $this->authorize('update', $this->server); if (! in_array($type, ['ed25519', 'rsa'], true)) { $this->dispatch('error', 'Invalid private key type.'); @@ -71,10 +77,10 @@ class Show extends Component 'name' => $keyData['name'], 'description' => $keyData['description'], 'private_key' => $keyData['private_key'], - 'team_id' => currentTeam()->id, + 'team_id' => $this->server->team_id, ]); - $this->privateKeys = PrivateKey::ownedByCurrentTeam()->get()->where('is_git_related', false); + $this->loadPrivateKeys(); $this->dispatch('copyPublicKeyToClipboard', publicKey: $privateKey->public_key); $this->dispatch('success', 'Private key created successfully.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 275622691a..77b31ce211 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -784,21 +784,21 @@ class Show extends Component public function loadHetznerTokens(): void { - $this->availableHetznerTokens = CloudProviderToken::ownedByCurrentTeam() + $this->availableHetznerTokens = CloudProviderToken::where('team_id', $this->server->team_id) ->where('provider', 'hetzner') ->get(); } public function loadVultrTokens(): void { - $this->availableVultrTokens = CloudProviderToken::ownedByCurrentTeam() + $this->availableVultrTokens = CloudProviderToken::where('team_id', $this->server->team_id) ->where('provider', 'vultr') ->get(); } public function loadDigitalOceanTokens(): void { - $this->availableDigitalOceanTokens = CloudProviderToken::ownedByCurrentTeam() + $this->availableDigitalOceanTokens = CloudProviderToken::where('team_id', $this->server->team_id) ->where('provider', 'digitalocean') ->get(); } diff --git a/app/Livewire/SharedVariables/Environment/Show.php b/app/Livewire/SharedVariables/Environment/Show.php index 4bc4679591..5e07fc4f72 100644 --- a/app/Livewire/SharedVariables/Environment/Show.php +++ b/app/Livewire/SharedVariables/Environment/Show.php @@ -42,7 +42,7 @@ class Show extends Component 'is_literal' => $data['is_literal'], 'comment' => $data['comment'] ?? null, 'type' => 'environment', - 'team_id' => currentTeam()->id, + 'team_id' => $this->project->team_id, ]); $this->environment->refresh(); $this->getDevView(); @@ -76,10 +76,10 @@ class Show extends Component private function formatEnvironmentVariables($variables) { - $isMember = auth()->user()?->isMember(); + $canViewValues = auth()->user()?->isAdminOfTeam($this->project->team_id) ?? false; - return $variables->map(function ($item) use ($isMember) { - if ($isMember) { + return $variables->map(function ($item) use ($canViewValues) { + if (! $canViewValues) { return "$item->key=(Hidden, only admins can view)"; } if ($item->is_shown_once) { @@ -167,7 +167,7 @@ class Show extends Component 'is_multiline' => false, 'is_literal' => false, 'type' => 'environment', - 'team_id' => currentTeam()->id, + 'team_id' => $this->project->team_id, ]); $count++; } diff --git a/app/Livewire/SharedVariables/Project/Show.php b/app/Livewire/SharedVariables/Project/Show.php index 6b7f26c26e..af06ce7d7f 100644 --- a/app/Livewire/SharedVariables/Project/Show.php +++ b/app/Livewire/SharedVariables/Project/Show.php @@ -35,7 +35,7 @@ class Show extends Component 'is_literal' => $data['is_literal'], 'comment' => $data['comment'] ?? null, 'type' => 'project', - 'team_id' => currentTeam()->id, + 'team_id' => $this->project->team_id, ]); $this->project->refresh(); $this->getDevView(); @@ -74,10 +74,10 @@ class Show extends Component private function formatEnvironmentVariables($variables) { - $isMember = auth()->user()?->isMember(); + $canViewValues = auth()->user()?->isAdminOfTeam($this->project->team_id) ?? false; - return $variables->map(function ($item) use ($isMember) { - if ($isMember) { + return $variables->map(function ($item) use ($canViewValues) { + if (! $canViewValues) { return "$item->key=(Hidden, only admins can view)"; } if ($item->is_shown_once) { @@ -159,7 +159,7 @@ class Show extends Component 'is_multiline' => false, 'is_literal' => false, 'type' => 'project', - 'team_id' => currentTeam()->id, + 'team_id' => $this->project->team_id, ]); $count++; } diff --git a/app/Livewire/SharedVariables/Server/Show.php b/app/Livewire/SharedVariables/Server/Show.php index 4701465739..ee89b2dcd3 100644 --- a/app/Livewire/SharedVariables/Server/Show.php +++ b/app/Livewire/SharedVariables/Server/Show.php @@ -39,7 +39,7 @@ class Show extends Component 'is_literal' => $data['is_literal'], 'comment' => $data['comment'] ?? null, 'type' => 'server', - 'team_id' => currentTeam()->id, + 'team_id' => $this->server->team_id, ]); $this->server->refresh(); $this->getDevView(); @@ -75,10 +75,10 @@ class Show extends Component private function formatEnvironmentVariables($variables) { - $isMember = auth()->user()?->isMember(); + $canViewValues = auth()->user()?->isAdminOfTeam($this->server->team_id) ?? false; - return $variables->map(function ($item) use ($isMember) { - if ($isMember) { + return $variables->map(function ($item) use ($canViewValues) { + if (! $canViewValues) { return "$item->key=(Hidden, only admins can view)"; } if ($item->is_shown_once) { @@ -173,7 +173,7 @@ class Show extends Component 'is_multiline' => false, 'is_literal' => false, 'type' => 'server', - 'team_id' => currentTeam()->id, + 'team_id' => $this->server->team_id, ]); $count++; } diff --git a/app/Livewire/SharedVariables/Team/Index.php b/app/Livewire/SharedVariables/Team/Index.php index 32da83672e..b7052590b2 100644 --- a/app/Livewire/SharedVariables/Team/Index.php +++ b/app/Livewire/SharedVariables/Team/Index.php @@ -35,7 +35,7 @@ class Index extends Component 'is_literal' => $data['is_literal'], 'comment' => $data['comment'] ?? null, 'type' => 'team', - 'team_id' => currentTeam()->id, + 'team_id' => $this->team->id, ]); $this->team->refresh(); $this->getDevView(); @@ -68,10 +68,10 @@ class Index extends Component private function formatEnvironmentVariables($variables) { - $isMember = auth()->user()?->isMember(); + $canViewValues = auth()->user()?->isAdminOfTeam($this->team->id) ?? false; - return $variables->map(function ($item) use ($isMember) { - if ($isMember) { + return $variables->map(function ($item) use ($canViewValues) { + if (! $canViewValues) { return "$item->key=(Hidden, only admins can view)"; } if ($item->is_shown_once) { @@ -158,7 +158,7 @@ class Index extends Component 'is_multiline' => false, 'is_literal' => false, 'type' => 'team', - 'team_id' => currentTeam()->id, + 'team_id' => $this->team->id, ]); $count++; } diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 02a9f8385d..a5f60fc0e8 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -397,7 +397,7 @@ class Change extends Component return; } - if (! PrivateKey::ownedByCurrentTeam()->find($this->privateKeyId)) { + if (! PrivateKey::where('team_id', $this->github_app->team_id)->find($this->privateKeyId)) { $this->dispatch('error', 'No private key found for this GitHub App.'); return; diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 6265345ee9..8c15e6853b 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -11,6 +11,7 @@ use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; +use Throwable; class StandaloneClickhouse extends BaseModel { @@ -18,6 +19,8 @@ class StandaloneClickhouse extends BaseModel protected array $auditExclude = ['last_online_at']; + public const DATA_DIRECTORY = '/var/lib/clickhouse'; + protected $fillable = [ 'uuid', 'name', @@ -185,6 +188,57 @@ class StandaloneClickhouse extends BaseModel } } + /** + * Name of the unnamed Docker volume that holds this database's data, or null. + * + * Before v4.4, adding a file mount removed the data volume from the compose file, so the + * container keeps its data in an unnamed volume. Recreating the container leaves that volume + * behind and starts on the configured data volume, so the current data seems lost. + */ + public function anonymousDataVolume(): ?string + { + $server = data_get($this, 'destination.server'); + if (! $server?->isFunctional()) { + return null; + } + + $format = '{{range .Mounts}}{{if eq .Destination "'.self::DATA_DIRECTORY.'"}}{{.Type}} {{.Name}}{{end}}{{end}}'; + try { + $output = instant_remote_process_with_timeout([ + 'docker inspect --format '.escapeshellarg($format).' '.escapeshellarg($this->uuid).' 2>/dev/null || true', + ], $server, false); + } catch (Throwable) { + return null; + } + + [$type, $name] = array_pad(explode(' ', trim((string) $output), 2), 2, ''); + if ($type !== 'volume' || $name === '' || $this->persistentStorages()->where('name', $name)->exists()) { + return null; + } + + return $name; + } + + /** + * Use the unnamed volume that holds the current data as the data volume, so a restart keeps that data. + */ + public function keepAnonymousDataVolume(string $volumeName): void + { + $storage = $this->persistentStorages()->where('mount_path', self::DATA_DIRECTORY)->first(); + if ($storage) { + $storage->update(['name' => $volumeName]); + + return; + } + + LocalPersistentVolume::create([ + 'name' => $volumeName, + 'mount_path' => self::DATA_DIRECTORY, + 'resource_id' => $this->id, + 'resource_type' => $this->getMorphClass(), + ]); + } + public function deleteVolumes() { $persistentStorages = $this->persistentStorages()->get() ?? collect(); diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 890a3dac48..b518abff7d 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -7,6 +7,7 @@ use App\Jobs\VolumeCloneJob; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationPreview; +use App\Models\Environment; use App\Models\EnvironmentVariable; use App\Models\Server; use App\Models\StandaloneDocker; @@ -275,8 +276,11 @@ function clone_application(Application $source, $destination, array $overrides = $uuid = $overrides['uuid'] ?? new_public_id(); $server = $destination->server; - if ($server->team_id !== currentTeam()->id) { - throw new RuntimeException('Destination does not belong to the current team.'); + $teamId = $server->team_id; + $sourceTeamId = $source->team()?->id; + $environmentTeamId = Environment::query()->find($overrides['environment_id'] ?? $source->environment_id)?->project?->team_id; + if ($sourceTeamId === null || $environmentTeamId === null || (int) $sourceTeamId !== (int) $teamId || (int) $environmentTeamId !== (int) $teamId) { + throw new RuntimeException('The application, the target environment, and the destination must belong to the same team.'); } // Prepare name and URL @@ -342,7 +346,7 @@ function clone_application(Application $source, $destination, array $overrides = ])->fill([ 'uuid' => new_public_id(), 'application_id' => $newApplication->id, - 'team_id' => currentTeam()->id, + 'team_id' => $teamId, ]); $newTask->save(); } diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index e32d3cf63e..da3b6215ed 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -231,8 +231,11 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d $serverTimezone = getServerTimezone(data_get($application, 'destination.server')); // Members should never see debug logs, even if an admin enabled debug mode - if ($is_debug_enabled && auth()->check() && auth()->user()->isMember()) { - $is_debug_enabled = false; + if ($is_debug_enabled && auth()->check()) { + $teamId = $application?->team()?->id; + if (is_null($teamId) || ! auth()->user()->isAdminOfTeam($teamId)) { + $is_debug_enabled = false; + } } $logs = data_get($application_deployment_queue, 'logs'); diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 1df2bedfb2..3f8ef0fce9 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -280,7 +280,7 @@ @endif @if ($buildPack !== 'dockercompose') @php - $hasBuildServers = \App\Models\Server::buildServers(currentTeam()->id)->exists(); + $hasBuildServers = \App\Models\Server::buildServers($application->team()?->id)->exists(); $buildServerOptions = [ ['value' => false, 'label' => 'Deployment server'], $hasBuildServers diff --git a/resources/views/livewire/project/database/clickhouse/general.blade.php b/resources/views/livewire/project/database/clickhouse/general.blade.php index 1a83934c8d..e7fe47fe0f 100644 --- a/resources/views/livewire/project/database/clickhouse/general.blade.php +++ b/resources/views/livewire/project/database/clickhouse/general.blade.php @@ -2,6 +2,29 @@
+ @if ($anonymousDataVolume) + +

Because of a bug in older Coolify versions, a file mount removed the data volume. This database + keeps its current data in the unnamed Docker volume {{ $anonymousDataVolume }}. + Starting or restarting it is blocked, because it would use the data volume and the current data + would seem lost.

+

An admin can click "Keep current data" to use this volume as the data volume. The previous data + volume is not used anymore, but stays on the server. It can contain data from before the file + mount was added.

+ @can('update', $database) +
+ +
+ @endcan +
+ @endif + diff --git a/resources/views/livewire/project/service/volume-backup/index.blade.php b/resources/views/livewire/project/service/volume-backup/index.blade.php index 34535d8fe7..26b835cdc8 100644 --- a/resources/views/livewire/project/service/volume-backup/index.blade.php +++ b/resources/views/livewire/project/service/volume-backup/index.blade.php @@ -268,7 +268,7 @@ default => 'neutral', }; $databaseBackupId = 'database:'.$databaseBackup->id; - $databaseS3 = $databaseBackup->s3?->team_id === currentTeam()->id ? $databaseBackup->s3 : null; + $databaseS3 = $databaseBackup->s3?->team_id === $serviceTeamId ? $databaseBackup->s3 : null; $databaseS3Tooltip = ! $databaseBackup->save_s3 ? 'S3 storage: Not configured' : ($databaseS3 ? 'S3 storage: '.$databaseS3->name.' (bucket: '.$databaseS3->bucket.')' : 'S3 storage: Unavailable'); @endphp
latestExecution; - $volumeS3 = $backup->s3?->team_id === currentTeam()->id ? $backup->s3 : null; + $volumeS3 = $backup->s3?->team_id === $serviceTeamId ? $backup->s3 : null; $volumeS3Tooltip = ! $backup->save_s3 ? 'S3 storage: Not configured' : ($volumeS3 ? 'S3 storage: '.$volumeS3->name.' (bucket: '.$volumeS3->bucket.')' : 'S3 storage: Unavailable'); $status = $latestExecution?->status; $statusLabel = match ($status) { diff --git a/resources/views/livewire/project/shared/storages/all.blade.php b/resources/views/livewire/project/shared/storages/all.blade.php index d213f827c1..f13568bb07 100644 --- a/resources/views/livewire/project/shared/storages/all.blade.php +++ b/resources/views/livewire/project/shared/storages/all.blade.php @@ -59,9 +59,6 @@ class="min-w-0 truncate text-[13px] font-medium text-neutral-950 dark:text-fg" title="{{ $form['name'] }}">{{ $form['name'] }}
- @if (blank($storage->host_path)) - Volume mount - @endif @if ($form['replacedExternalVolume']) Replaces the external volume '{{ $form['replacedExternalVolume'] }}'. Copy the data into the external volume, then delete this entry to use it. @@ -202,8 +199,6 @@ SQLite database {{ $storage->standaloneSqlite->name }} - @elseif (blank($storage->host_path)) - Volume mount @endif diff --git a/tests/Feature/Application/GithubSourceChangeTest.php b/tests/Feature/Application/GithubSourceChangeTest.php index 44baaf8fb6..ccbd47005b 100644 --- a/tests/Feature/Application/GithubSourceChangeTest.php +++ b/tests/Feature/Application/GithubSourceChangeTest.php @@ -9,6 +9,7 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -884,3 +885,50 @@ describe('GitHub Source Change Component', function () { ->assertSee('Setup incomplete'); }); }); + +describe('GitHub App name sync after the user switches the session team', function () { + beforeEach(function () { + Storage::fake('ssh-keys'); + $this->otherTeam = Team::factory()->create(); + $this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']); + $this->appKey = PrivateKey::create(['name' => 'App Key', 'private_key' => validPrivateKey(), 'team_id' => $this->team->id]); + $this->otherTeamKey = PrivateKey::create(['name' => 'Other Team Key', 'private_key' => validPrivateKey(), 'team_id' => $this->otherTeam->id]); + $this->githubApp = GithubApp::create([ + 'name' => 'Test GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 12345, + 'installation_id' => 67890, + 'private_key_id' => $this->appKey->id, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + ]); + + Http::preventStrayRequests(); + Http::fake([ + 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['date' => now()->toRfc7231String()]), + 'https://api.github.com/app' => Http::response(['slug' => 'synced-app']), + ]); + + $this->component = Livewire::withQueryParams(['github_app_uuid' => $this->githubApp->uuid])->test(Change::class); + session(['currentTeam' => $this->otherTeam]); + }); + + test('rejects a private key of the session team', function () { + $this->component->set('privateKeyId', $this->otherTeamKey->id) + ->call('updateGithubAppName') + ->assertDispatched('error', 'No private key found for this GitHub App.'); + + expect($this->githubApp->fresh()->private_key_id)->toBe($this->appKey->id); + Http::assertNothingSent(); + }); + + test('syncs the name with a private key of the GitHub App team', function () { + $this->component->call('updateGithubAppName') + ->assertDispatched('success'); + + expect($this->githubApp->fresh()->name)->toBe('synced-app'); + }); +}); diff --git a/tests/Feature/ApplicationSourceCrossTeamTest.php b/tests/Feature/ApplicationSourceCrossTeamTest.php index 6db2383be5..5d6fd1c3dc 100644 --- a/tests/Feature/ApplicationSourceCrossTeamTest.php +++ b/tests/Feature/ApplicationSourceCrossTeamTest.php @@ -168,3 +168,77 @@ test('privateKeyId is locked so submit() cannot persist a client-supplied foreig Livewire::test(Source::class, ['application' => $this->applicationA]) ->set('privateKeyId', $this->victimPrivateKey->id); })->throws(CannotUpdateLockedPropertyException::class); + +describe('after the user switches the session team', function () { + beforeEach(function () { + Http::fake([ + 'https://api.github.com/repos/*' => Http::response(['id' => 123]), + ]); + $this->teamB->members()->attach($this->userA->id, ['role' => 'owner']); + $this->applicationA->update(['git_repository' => 'coollabsio/coolify']); + + $this->ownKey = makePrivateKey('own-ssh-key', 'OWN_KEY_MATERIAL', 'own-fingerprint', $this->teamA->id); + $this->ownGithubApp = GithubApp::create([ + 'name' => 'own-github-app', + 'team_id' => $this->teamA->id, + 'app_id' => 1, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => false, + ]); + $this->victimGithubApp->update(['app_id' => 2]); + $this->systemWideGithubApp = GithubApp::create([ + 'name' => 'system-wide-github-app', + 'team_id' => Team::factory()->create()->id, + 'app_id' => 3, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => false, + 'is_system_wide' => true, + ]); + + $this->component = Livewire::test(Source::class, ['application' => $this->applicationA->fresh()]); + session(['currentTeam' => $this->teamB]); + }); + + test('lists only keys and sources of the application team and system-wide sources', function () { + $component = Livewire::test(Source::class, ['application' => $this->applicationA->fresh()]); + + expect($component->get('privateKeys')->pluck('id')->all())->toBe([$this->ownKey->id]) + ->and($component->get('sources')->pluck('id')->sort()->values()->all()) + ->toBe([$this->ownGithubApp->id, $this->systemWideGithubApp->id]); + }); + + test('setPrivateKey rejects a key of the session team', function () { + $this->component->call('setPrivateKey', $this->victimPrivateKey->id); + + expect($this->applicationA->refresh()->private_key_id)->toBeNull(); + }); + + test('setPrivateKey accepts a key of the application team', function () { + $this->component->call('setPrivateKey', $this->ownKey->id); + + expect($this->applicationA->refresh()->private_key_id)->toBe($this->ownKey->id); + }); + + test('changeSource rejects a source of the session team', function () { + try { + $this->component->call('changeSource', $this->victimGithubApp->id, GithubApp::class); + } catch (Throwable $e) { + } + + expect($this->applicationA->refresh()->source_id)->toBeNull(); + }); + + test('changeSource accepts a source of the application team', function () { + $this->component->call('changeSource', $this->ownGithubApp->id, GithubApp::class); + + expect($this->applicationA->refresh()->source_id)->toBe($this->ownGithubApp->id); + }); + + test('changeSource accepts a system-wide source', function () { + $this->component->call('changeSource', $this->systemWideGithubApp->id, GithubApp::class); + + expect($this->applicationA->refresh()->source_id)->toBe($this->systemWideGithubApp->id); + }); +}); diff --git a/tests/Feature/Authorization/CloneMeCrossTenantTest.php b/tests/Feature/Authorization/CloneMeCrossTenantTest.php new file mode 100644 index 0000000000..01174d5d8c --- /dev/null +++ b/tests/Feature/Authorization/CloneMeCrossTenantTest.php @@ -0,0 +1,178 @@ +withoutVite(); + InstanceSettings::forceCreate(['id' => 0]); + Server::flushIdentityMap(); + + $this->user = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->user->teams()->attach($this->teamA, ['role' => 'admin']); + $this->user->teams()->attach($this->teamB, ['role' => 'owner']); + + $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->destinationA = StandaloneDocker::where('server_id', $this->serverA->id)->firstOrFail(); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + $this->applicationA = Application::factory()->create([ + 'environment_id' => $this->environmentA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => $this->destinationA->getMorphClass(), + ]); + + $this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]); + $this->destinationB = StandaloneDocker::where('server_id', $this->serverB->id)->firstOrFail(); + + $this->actingAs($this->user); + session(['currentTeam' => $this->teamA]); +}); + +afterEach(function () { + Server::flushIdentityMap(); +}); + +function cloneMeAfterTeamSwitch(object $test): Testable +{ + $component = Livewire::test(CloneMe::class, [ + 'project_uuid' => $test->projectA->uuid, + 'environment_uuid' => $test->environmentA->uuid, + ]); + session(['currentTeam' => $test->teamB]); + + return $component; +} + +test('lists only servers of the project team', function () { + session(['currentTeam' => $this->teamA]); + + $component = Livewire::test(CloneMe::class, [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]); + + expect($component->get('servers')->pluck('id')->all())->toBe([$this->serverA->id]); +}); + +test('project clone with a session team destination creates nothing after a team switch', function () { + $projectCount = Project::count(); + $environmentCount = Environment::count(); + + cloneMeAfterTeamSwitch($this) + ->set('selectedDestination', $this->destinationB->uuid) + ->set('newName', 'zz-cross-team-clone') + ->call('clone', 'project') + ->assertDispatched('error'); + + expect(Project::count())->toBe($projectCount) + ->and(Environment::count())->toBe($environmentCount) + ->and(Application::count())->toBe(1); +}); + +test('environment clone with a session team destination creates nothing after a team switch', function () { + $environmentCount = Environment::count(); + + cloneMeAfterTeamSwitch($this) + ->set('selectedDestination', $this->destinationB->uuid) + ->set('newName', 'zz-cross-team-env') + ->call('clone', 'environment') + ->assertDispatched('error'); + + expect(Environment::count())->toBe($environmentCount) + ->and(Application::count())->toBe(1); +}); + +test('a member of the project team cannot clone after switching to a team they own', function () { + $this->user->teams()->updateExistingPivot($this->teamA->id, ['role' => 'member']); + $this->user->unsetRelation('teams'); + $projectCount = Project::count(); + + cloneMeAfterTeamSwitch($this) + ->set('selectedDestination', $this->destinationA->uuid) + ->set('newName', 'zz-member-clone') + ->call('clone', 'project') + ->assertDispatched('error'); + + expect(Project::count())->toBe($projectCount) + ->and(Application::count())->toBe(1); +}); + +test('project clone after a team switch stays in the source project team', function () { + $database = StandalonePostgresql::create([ + 'name' => 'pg-clone', + 'uuid' => new_public_id(), + 'postgres_password' => 'secret', + 'postgres_user' => 'postgres', + 'postgres_db' => 'postgres', + 'environment_id' => $this->environmentA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => $this->destinationA->getMorphClass(), + 'status' => 'exited', + ]); + ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', 'database_type' => $database->getMorphClass(), 'database_id' => $database->id, + 'team_id' => $this->teamA->id, + ]); + + cloneMeAfterTeamSwitch($this) + ->set('selectedDestination', $this->destinationA->uuid) + ->set('newName', 'zz-same-team-clone') + ->call('clone', 'project') + ->assertNotDispatched('error') + ->assertRedirect(); + + $project = Project::where('name', 'zz-same-team-clone')->sole(); + $environment = $project->environments()->where('name', $this->environmentA->name)->sole(); + $clonedDatabase = StandalonePostgresql::where('environment_id', $environment->id)->sole(); + + expect($project->team_id)->toBe($this->teamA->id) + ->and($environment->applications()->count())->toBe(1) + ->and($clonedDatabase->scheduledBackups()->sole()->team_id)->toBe($this->teamA->id); +}); + +test('a failing clone step leaves no partial project behind', function () { + StandalonePostgresql::create([ + 'name' => 'pg-fails', + 'uuid' => new_public_id(), + 'postgres_password' => 'secret', + 'postgres_user' => 'postgres', + 'postgres_db' => 'postgres', + 'environment_id' => $this->environmentA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => $this->destinationA->getMorphClass(), + 'status' => 'exited', + ]); + StandalonePostgresql::created(fn () => throw new RuntimeException('Database clone failed.')); + $projectCount = Project::count(); + $environmentCount = Environment::count(); + + Livewire::test(CloneMe::class, [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]) + ->set('selectedDestination', $this->destinationA->uuid) + ->set('newName', 'zz-failing-clone') + ->call('clone', 'project') + ->assertDispatched('error'); + + expect(Project::count())->toBe($projectCount) + ->and(Environment::count())->toBe($environmentCount) + ->and(Application::count())->toBe(1); +}); diff --git a/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php b/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php index ec78d0f30d..54943eb9e5 100644 --- a/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php +++ b/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php @@ -288,3 +288,59 @@ test('API shows env values for admin with read:sensitive token', function () { expect($unlocked)->not->toBeNull(); expect($unlocked)->toHaveKey('value'); }); + +// --- Session team differs from the resource team --- + +test('member dev view hides values after switching to a team they own', function () { + $ownTeam = Team::factory()->create(); + $this->member->teams()->attach($ownTeam, ['role' => 'owner']); + $this->actingAs($this->member); + session(['currentTeam' => $ownTeam]); + + $component = Livewire::test(EnvironmentVariableAll::class, [ + 'resource' => $this->application, + ])->call('switch'); + + expect($component->get('variables'))->not->toContain('secret-unlocked-value') + ->and($component->get('variables'))->toContain('UNLOCKED_VAR=(Hidden'); + $component->assertDontSee('secret-unlocked-value'); +}); + +test('admin dev view shows values while the session is on a team where they are a member', function () { + $otherTeam = Team::factory()->create(); + $this->admin->teams()->attach($otherTeam, ['role' => 'member']); + $this->actingAs($this->admin); + session(['currentTeam' => $otherTeam]); + + $component = Livewire::test(EnvironmentVariableAll::class, [ + 'resource' => $this->application, + ])->call('switch'); + + expect($component->get('variables'))->toContain('UNLOCKED_VAR=secret-unlocked-value'); +}); + +test('user outside the resource team cannot open the dev view', function () { + $outsider = User::factory()->create(); + $outsiderTeam = Team::factory()->create(); + $outsider->teams()->attach($outsiderTeam, ['role' => 'owner']); + $this->actingAs($outsider); + session(['currentTeam' => $outsiderTeam]); + + Livewire::test(EnvironmentVariableAll::class, [ + 'resource' => $this->application, + ])->call('switch')->assertForbidden(); +}); + +test('member keeps isValueHidden after switching to a team they own', function () { + $ownTeam = Team::factory()->create(); + $this->member->teams()->attach($ownTeam, ['role' => 'owner']); + $this->actingAs($this->member); + session(['currentTeam' => $ownTeam]); + + $component = Livewire::test(EnvironmentVariableShow::class, [ + 'env' => $this->unlockedEnv, + 'type' => 'application', + ]); + + expect($component->get('isValueHidden'))->toBeTrue(); +}); diff --git a/tests/Feature/Authorization/ProjectEnvironmentAuthorizationTest.php b/tests/Feature/Authorization/ProjectEnvironmentAuthorizationTest.php index d1e01f0e67..d34df9a132 100644 --- a/tests/Feature/Authorization/ProjectEnvironmentAuthorizationTest.php +++ b/tests/Feature/Authorization/ProjectEnvironmentAuthorizationTest.php @@ -53,3 +53,21 @@ test('member cannot create environment in project', function () { expect(Environment::where('name', 'staging')->exists())->toBeFalse(); }); + +test('member cannot create environment in project after switching to an owned team', function () { + $ownTeam = Team::factory()->create(); + $this->member->teams()->attach($ownTeam, ['role' => 'owner']); + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(Show::class, ['project_uuid' => $this->project->uuid]); + + $this->member->load('teams'); + session(['currentTeam' => $ownTeam]); + + $component->set('name', 'staging') + ->call('submit') + ->assertDispatched('error'); + + expect(Environment::where('name', 'staging')->exists())->toBeFalse(); +}); diff --git a/tests/Feature/Authorization/ResourceOperationsCrossTenantTest.php b/tests/Feature/Authorization/ResourceOperationsCrossTenantTest.php index 439d04ef9d..8bb086a972 100644 --- a/tests/Feature/Authorization/ResourceOperationsCrossTenantTest.php +++ b/tests/Feature/Authorization/ResourceOperationsCrossTenantTest.php @@ -5,11 +5,16 @@ use App\Models\Application; use App\Models\Environment; use App\Models\InstanceSettings; use App\Models\Project; +use App\Models\ScheduledDatabaseBackup; +use App\Models\ScheduledTask; use App\Models\Server; +use App\Models\Service; use App\Models\StandaloneDocker; +use App\Models\StandalonePostgresql; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Livewire\Features\SupportTesting\Testable; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -114,3 +119,143 @@ test('StandaloneDockerPolicy denies update for cross-team user', function () { test('StandaloneDockerPolicy allows update for same-team user', function () { expect($this->userA->can('update', $this->destinationA))->toBeTrue(); }); + +describe('after the session team changes', function () { + beforeEach(function () { + Server::flushIdentityMap(); + $this->userA->teams()->attach($this->teamB, ['role' => 'owner']); + $this->userA->unsetRelation('teams'); + $this->secondEnvironmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + }); + + function resourceOperationsAfterTeamSwitch(object $test, $resource): Testable + { + $component = Livewire::test(ResourceOperations::class, ['resource' => $resource]); + session(['currentTeam' => $test->teamB]); + + return $component; + } + + test('lists the projects and servers of the resource team', function () { + session(['currentTeam' => $this->teamB]); + + $component = Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA]); + + expect($component->get('projects')->pluck('id')->all())->toBe([$this->projectA->id]) + ->and($component->get('servers')->pluck('id')->all())->toBe([$this->serverA->id]); + }); + + test('cloneTo rejects a destination of the session team', function () { + resourceOperationsAfterTeamSwitch($this, $this->applicationA) + ->call('cloneTo', $this->destinationB->uuid) + ->assertHasErrors('destination_id'); + + expect(Application::count())->toBe(1); + }); + + test('cloneTo rejects an environment and destination of another team', function () { + resourceOperationsAfterTeamSwitch($this, $this->applicationA) + ->call('cloneTo', $this->destinationB->uuid, $this->environmentB->id) + ->assertHasErrors('environment_id'); + + expect(Application::count())->toBe(1); + }); + + test('moveTo rejects an environment of the session team', function () { + resourceOperationsAfterTeamSwitch($this, $this->applicationA) + ->call('moveTo', $this->environmentB->id); + + expect($this->applicationA->fresh()->environment_id)->toBe($this->environmentA->id); + }); + + test('migrateTo rejects a destination of the session team', function () { + resourceOperationsAfterTeamSwitch($this, $this->applicationA) + ->call('migrateTo', $this->destinationB->uuid) + ->assertHasErrors(['destination_id' => 'Destination not found.']); + }); + + test('cloneTo and moveTo still work inside the resource team', function () { + resourceOperationsAfterTeamSwitch($this, $this->applicationA) + ->call('cloneTo', $this->destinationA->uuid, $this->secondEnvironmentA->id) + ->assertHasNoErrors() + ->assertRedirect(); + + $clone = Application::whereKeyNot($this->applicationA->id)->sole(); + expect($clone->environment_id)->toBe($this->secondEnvironmentA->id) + ->and($clone->destination_id)->toBe($this->destinationA->id); + + resourceOperationsAfterTeamSwitch($this, $this->applicationA) + ->call('moveTo', $this->secondEnvironmentA->id) + ->assertRedirect(); + + expect($this->applicationA->fresh()->environment_id)->toBe($this->secondEnvironmentA->id); + }); + + test('cloned application tasks belong to the resource team', function () { + ScheduledTask::factory()->create(['application_id' => $this->applicationA->id, 'team_id' => $this->teamA->id]); + + resourceOperationsAfterTeamSwitch($this, $this->applicationA) + ->call('cloneTo', $this->destinationA->uuid) + ->assertRedirect(); + + $clone = Application::whereKeyNot($this->applicationA->id)->sole(); + expect($clone->scheduled_tasks()->sole()->team_id)->toBe($this->teamA->id); + }); + + test('cloned database backups belong to the resource team', function () { + $database = StandalonePostgresql::create([ + 'name' => 'pg', 'uuid' => new_public_id(), 'postgres_password' => 'secret', 'postgres_user' => 'postgres', + 'postgres_db' => 'postgres', 'environment_id' => $this->environmentA->id, 'status' => 'exited', + 'destination_id' => $this->destinationA->id, 'destination_type' => $this->destinationA->getMorphClass(), + ]); + ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', 'database_type' => $database->getMorphClass(), 'database_id' => $database->id, + 'team_id' => $this->teamA->id, + ]); + + resourceOperationsAfterTeamSwitch($this, $database) + ->call('cloneTo', $this->destinationA->uuid) + ->assertRedirect(); + + $clone = StandalonePostgresql::whereKeyNot($database->id)->sole(); + expect($clone->scheduledBackups()->sole()->team_id)->toBe($this->teamA->id); + }); + + test('cloned service tasks belong to the resource team', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environmentA->id, 'server_id' => $this->serverA->id, + 'destination_id' => $this->destinationA->id, 'destination_type' => $this->destinationA->getMorphClass(), + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ]); + ScheduledTask::factory()->create(['service_id' => $service->id, 'team_id' => $this->teamA->id]); + + resourceOperationsAfterTeamSwitch($this, $service) + ->call('cloneTo', $this->destinationA->uuid) + ->assertRedirect(); + + $clone = Service::whereKeyNot($service->id)->sole(); + expect($clone->scheduled_tasks()->sole()->team_id)->toBe($this->teamA->id); + }); + + test('a member of the resource team cannot clone or move after switching to an own team', function () { + $member = User::factory()->create(); + $member->teams()->attach($this->teamA, ['role' => 'member']); + $member->teams()->attach($this->teamB, ['role' => 'owner']); + $this->actingAs($member); + + resourceOperationsAfterTeamSwitch($this, $this->applicationA) + ->call('cloneTo', $this->destinationA->uuid) + ->call('moveTo', $this->secondEnvironmentA->id); + + expect(Application::count())->toBe(1) + ->and($this->applicationA->fresh()->environment_id)->toBe($this->environmentA->id); + }); + + test('clone_application rejects a destination outside the environment team', function () { + session(['currentTeam' => $this->teamB]); + + expect(fn () => clone_application($this->applicationA, $this->destinationB, ['environment_id' => $this->environmentA->id])) + ->toThrow(RuntimeException::class, 'same team'); + expect(Application::count())->toBe(1); + }); +}); diff --git a/tests/Feature/BackupEditValidationTest.php b/tests/Feature/BackupEditValidationTest.php index ab6d442483..66643ffc46 100644 --- a/tests/Feature/BackupEditValidationTest.php +++ b/tests/Feature/BackupEditValidationTest.php @@ -434,11 +434,29 @@ it('defaults to the first available storage when multiple storages are available expect($backup->s3_storage_id)->toBe($firstS3->id); }); -it('accepts the S3 storage scope passed to the component', function () { +it('accepts a root team S3 storage for the instance database backup', function () { + $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + $this->user->teams()->syncWithoutDetaching([$rootTeam->id => ['role' => 'owner']]); + session(['currentTeam' => $this->team]); $s3 = createS3StorageForBackupEditValidationTest(0); - $backup = createBackupForEditValidationTest($this->team, [ + $server = Server::factory()->create(['id' => 0, 'team_id' => 0, 'ip' => '127.0.0.1']); + $database = new StandalonePostgresql; + $database->forceFill([ + 'id' => 0, + 'name' => 'coolify-db', + 'postgres_user' => 'coolify', + 'postgres_password' => 'password', + 'postgres_db' => 'coolify', + 'destination_type' => StandaloneDocker::class, + 'destination_id' => StandaloneDocker::where('server_id', $server->id)->firstOrFail()->id, + 'environment_id' => null, + ])->save(); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', 'save_s3' => false, - 's3_storage_id' => null, + 'database_type' => StandalonePostgresql::class, + 'database_id' => 0, + 'team_id' => 0, ]); Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => collect([$s3])]) diff --git a/tests/Feature/BackupS3StorageListResourceTeamTest.php b/tests/Feature/BackupS3StorageListResourceTeamTest.php new file mode 100644 index 0000000000..90f87493b4 --- /dev/null +++ b/tests/Feature/BackupS3StorageListResourceTeamTest.php @@ -0,0 +1,130 @@ + 'file']); + Server::flushIdentityMap(); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + + $this->teamA = Team::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->teamA, ['role' => 'member']); + $this->user->teams()->attach($this->teamB, ['role' => 'owner']); + + $server = Server::factory()->create(['team_id' => $this->teamA->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->service = Service::factory()->create([ + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + $this->serviceDatabase = ServiceDatabase::create([ + 'service_id' => $this->service->id, + 'name' => 'zz-s3-list-db', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]); + $this->serviceApplication = ServiceApplication::create([ + 'service_id' => $this->service->id, + 'name' => 'zz-s3-list-app', + 'image' => 'nginx:alpine', + ]); + $this->postgres = StandalonePostgresql::create([ + 'name' => 'zz-s3-list-pg', + 'image' => 'postgres:16-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $this->environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $this->ownS3 = createS3ForResourceTeamListTest($this->teamA, 'team-a-s3'); + createS3ForResourceTeamListTest($this->teamA, 'team-a-unusable-s3', false); + createS3ForResourceTeamListTest($this->teamB, 'team-b-s3'); + + $this->actingAs($this->user); + session(['currentTeam' => $this->teamA]); +}); + +function createS3ForResourceTeamListTest(Team $team, string $name, bool $isUsable = true): S3Storage +{ + return S3Storage::create([ + 'name' => $name, + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => $name.'-bucket', + 'endpoint' => 'https://s3.example.com', + 'is_usable' => $isUsable, + 'team_id' => $team->id, + ]); +} + +function switchSessionTeamForResourceTeamListTest(Team $team): void +{ + session(['currentTeam' => $team]); + Cache::flush(); +} + +test('embedded service database offers only S3 storages of the service team', function (string $sessionTeam) { + switchSessionTeamForResourceTeamListTest($this->{$sessionTeam}); + + Livewire::test(ServiceIndex::class, ['serviceApplication' => $this->serviceDatabase->fresh(), 'embedded' => true]) + ->assertSet('s3s', fn ($s3s) => $s3s->pluck('id')->all() === [$this->ownS3->id]); +})->with(['same session team' => 'teamA', 'after a team switch' => 'teamB']); + +test('embedded service application offers only S3 storages of the service team after a team switch', function () { + switchSessionTeamForResourceTeamListTest($this->teamB); + + Livewire::test(ServiceIndex::class, ['serviceApplication' => $this->serviceApplication->fresh(), 'embedded' => true]) + ->assertSet('s3s', fn ($s3s) => $s3s->pluck('id')->all() === [$this->ownS3->id]); +}); + +test('database backup page offers only usable S3 storages of the database team', function () { + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => '0 0 * * *', + 'database_id' => $this->postgres->id, + 'database_type' => $this->postgres->getMorphClass(), + ]); + $url = route('project.database.backup.s3', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'database_uuid' => $this->postgres->uuid, + 'backup_uuid' => $backup->uuid, + ]); + + $this->get($url) + ->assertOk() + ->assertSee('team-a-s3') + ->assertDontSee('team-a-unusable-s3') + ->assertDontSee('team-b-s3'); + + switchSessionTeamForResourceTeamListTest($this->teamB); + $this->get($url)->assertRedirect(route('dashboard')); +}); diff --git a/tests/Feature/CloudProviderLinkDropdownTest.php b/tests/Feature/CloudProviderLinkDropdownTest.php index 59cf37f97b..1f14482cf6 100644 --- a/tests/Feature/CloudProviderLinkDropdownTest.php +++ b/tests/Feature/CloudProviderLinkDropdownTest.php @@ -190,3 +190,20 @@ it('shows Vultr search errors in the modal', function () { ->assertSee('Failed to search Vultr instances:') ->assertSee('invalid token'); }); + +it('reloads only cloud provider tokens of the server team after the session team changes', function () { + $otherTeam = Team::factory()->create(); + $otherTeam->members()->attach($this->user->id, ['role' => 'owner']); + $sameTeamToken = CloudProviderToken::factory()->create(['team_id' => $this->team->id, 'provider' => 'hetzner']); + CloudProviderToken::factory()->create(['team_id' => $otherTeam->id, 'provider' => 'hetzner']); + CloudProviderToken::factory()->create(['team_id' => $otherTeam->id, 'provider' => 'vultr']); + CloudProviderToken::factory()->create(['team_id' => $otherTeam->id, 'provider' => 'digitalocean']); + + $component = Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]); + session(['currentTeam' => $otherTeam]); + $component->call('handleServerValidated'); + + expect($component->get('availableHetznerTokens')->pluck('id')->all())->toBe([$sameTeamToken->id]) + ->and($component->get('availableVultrTokens'))->toBeEmpty() + ->and($component->get('availableDigitalOceanTokens'))->toBeEmpty(); +}); diff --git a/tests/Feature/CloudProviderTokenShowTest.php b/tests/Feature/CloudProviderTokenShowTest.php index 8aa200ded0..fad6be2287 100644 --- a/tests/Feature/CloudProviderTokenShowTest.php +++ b/tests/Feature/CloudProviderTokenShowTest.php @@ -1,12 +1,14 @@ assertSee('Production Hetzner') ->assertSee('Used for production servers in the EU region.'); }); + +describe('after the user switches the session team', function () { + beforeEach(function () { + $this->withoutDefer(); + Http::fake(['*' => Http::response(['servers' => []])]); + + $this->otherTeam = Team::factory()->create(); + $this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->sameTeamToken = CloudProviderToken::factory()->create(['team_id' => $this->team->id, 'provider' => 'hetzner']); + $this->otherTeamToken = CloudProviderToken::factory()->create(['team_id' => $this->otherTeam->id, 'provider' => 'hetzner']); + + $this->component = Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]); + session(['currentTeam' => $this->otherTeam]); + }); + + test('lists only tokens of the server team', function () { + $tokenIds = $this->component->call('loadTokens')->get('cloudProviderTokens')->pluck('id')->all(); + + expect($tokenIds)->toBe([$this->sameTeamToken->id]); + }); + + test('rejects a token of the session team', function () { + $this->component->call('setCloudProviderToken', $this->otherTeamToken->id) + ->assertDispatched('error', 'You are not allowed to use this token.'); + + expect($this->server->fresh()->cloud_provider_token_id)->toBeNull(); + }); + + test('accepts a token of the server team and audits it for the server team', function () { + $this->component->call('setCloudProviderToken', $this->sameTeamToken->id) + ->assertDispatched('success'); + + expect($this->server->fresh()->cloud_provider_token_id)->toBe($this->sameTeamToken->id) + ->and(AuditEvent::query()->where('event', 'ui.server.cloud_token_assigned')->sole()->team_id)->toBe($this->team->id); + }); +}); diff --git a/tests/Feature/CrossTeamDestinationAttachTest.php b/tests/Feature/CrossTeamDestinationAttachTest.php index cccfa2b540..891d87e494 100644 --- a/tests/Feature/CrossTeamDestinationAttachTest.php +++ b/tests/Feature/CrossTeamDestinationAttachTest.php @@ -3,6 +3,7 @@ use App\Actions\Docker\GetContainersStatus; use App\Livewire\Project\Shared\Destination; use App\Models\Application; +use App\Models\ApplicationDeploymentQueue; use App\Models\Environment; use App\Models\InstanceSettings; use App\Models\Project; @@ -256,3 +257,64 @@ describe('Destination::removeServer', function () { ->exists())->toBeFalse(); }); }); + +describe('Destination after the user switches the session team', function () { + beforeEach(function () { + Server::flushIdentityMap(); + $this->userA->teams()->attach($this->teamB, ['role' => 'owner']); + foreach ([$this->serverA2, $this->serverB] as $server) { + $server->settings->update(['is_reachable' => true, 'is_usable' => true]); + } + + $this->component = Livewire::test(Destination::class, ['resource' => $this->applicationA]); + session(['currentTeam' => $this->teamB]); + }); + + test('lists only networks of the application team', function () { + $networkIds = $this->component->call('loadData')->get('networks')->pluck('id'); + + expect($networkIds)->toContain($this->destinationA2->id) + ->not->toContain($this->destinationB->id); + }); + + test('cannot attach a server of the session team to the application', function () { + try { + $this->component->call('addServer', $this->destinationB->id, $this->serverB->id); + } catch (Throwable $e) { + } + + expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0); + }); + + test('can attach a server of the application team', function () { + $this->component->call('addServer', $this->destinationA2->id, $this->serverA2->id); + + expect($this->applicationA->fresh()->additional_networks->pluck('id')->all())->toBe([$this->destinationA2->id]); + }); + + test('cannot promote a network of the session team', function () { + try { + $this->component->call('promote', $this->destinationB->id, $this->serverB->id); + } catch (Throwable $e) { + } + + expect($this->applicationA->fresh()->destination_id)->toBe($this->destinationA->id); + }); + + test('can promote a network of the application team', function () { + $this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]); + + $this->component->call('promote', $this->destinationA2->id, $this->serverA2->id); + + expect($this->applicationA->fresh()->destination_id)->toBe($this->destinationA2->id); + }); + + test('cannot redeploy to a server of the session team', function () { + try { + $this->component->call('redeploy', $this->destinationB->id, $this->serverB->id); + } catch (Throwable $e) { + } + + expect(ApplicationDeploymentQueue::query()->where('server_id', $this->serverB->id)->exists())->toBeFalse(); + }); +}); diff --git a/tests/Feature/DeploymentCancellationCleanupTest.php b/tests/Feature/DeploymentCancellationCleanupTest.php index 065b0b3057..6912ae604a 100644 --- a/tests/Feature/DeploymentCancellationCleanupTest.php +++ b/tests/Feature/DeploymentCancellationCleanupTest.php @@ -154,3 +154,28 @@ test('cancelling does not touch servers of another team', function () { expect(cancelCleanupCommandsOn($this->remoteCommands, '203.0.113.30', 'docker'))->toBeEmpty() ->and($this->deployment->fresh()->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value); }); + +test('cancelling after switching to another team still cleans up and starts the next queued deployment', function () { + $otherTeam = Team::factory()->create(); + $otherTeam->members()->attach($this->user->id, ['role' => 'owner']); + $nextApplication = Application::factory()->create([ + 'environment_id' => $this->application->environment_id, + 'destination_id' => $this->application->destination_id, + 'destination_type' => $this->application->destination_type, + ]); + $queued = ApplicationDeploymentQueue::create([ + 'application_id' => $nextApplication->id, + 'deployment_uuid' => 'cancel-cleanup-next', + 'server_id' => $this->server->id, + 'destination_id' => $this->application->destination_id, + 'status' => ApplicationDeploymentStatus::QUEUED->value, + ]); + $this->actingAs($this->user); + + $component = Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment]); + session(['currentTeam' => $otherTeam]); + $component->call('cancel'); + + assertCancelCleanupRanCorrectly($this->remoteCommands, $this->deployment); + expect($queued->fresh()->status)->toBe(ApplicationDeploymentStatus::IN_PROGRESS->value); +}); diff --git a/tests/Feature/DeploymentDebugLogVisibilityTest.php b/tests/Feature/DeploymentDebugLogVisibilityTest.php new file mode 100644 index 0000000000..41384552ab --- /dev/null +++ b/tests/Feature/DeploymentDebugLogVisibilityTest.php @@ -0,0 +1,88 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->ownTeam = Team::factory()->create(); + $this->member = User::factory()->create(); + $this->team->members()->attach($this->member->id, ['role' => 'member']); + $this->ownTeam->members()->attach($this->member->id, ['role' => 'owner']); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->application->settings->update(['is_debug_enabled' => true]); + + $this->deployment = ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => 'debug-visibility-uuid', + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'status' => ApplicationDeploymentStatus::FINISHED->value, + ]); + $this->deployment->addLogEntry('visible-line'); + $this->deployment->addLogEntry('hidden-debug-line', hidden: true); +}); + +afterEach(function () { + Server::flushIdentityMap(); +}); + +test('deployment log lines hide debug output from a member whose session is on a team they own', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->ownTeam]); + + $lines = decode_remote_command_output($this->deployment->fresh())->pluck('line')->implode("\n"); + + expect($lines)->toContain('visible-line') + ->not->toContain('hidden-debug-line'); +}); + +test('deployment log lines show debug output to a resource team admin whose session is on another team', function () { + $this->team->members()->updateExistingPivot($this->member->id, ['role' => 'admin']); + $this->ownTeam->members()->updateExistingPivot($this->member->id, ['role' => 'member']); + $this->actingAs($this->member); + session(['currentTeam' => $this->ownTeam]); + + $lines = decode_remote_command_output($this->deployment->fresh())->pluck('line')->implode("\n"); + + expect($lines)->toContain('hidden-debug-line'); +}); + +test('copied deployment logs hide debug output from a member after switching to a team they own', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment]); + + session(['currentTeam' => $this->ownTeam]); + + $markdown = $component->instance()->copyLogsToClipboard(); + + expect($markdown)->toContain('visible-line') + ->not->toContain('hidden-debug-line'); +}); diff --git a/tests/Feature/EnvironmentVariable/AutocompleteResourceTeamTest.php b/tests/Feature/EnvironmentVariable/AutocompleteResourceTeamTest.php new file mode 100644 index 0000000000..57de2cba32 --- /dev/null +++ b/tests/Feature/EnvironmentVariable/AutocompleteResourceTeamTest.php @@ -0,0 +1,76 @@ + 0]); + + $this->resourceTeam = Team::factory()->create(); + $this->sessionTeam = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->resourceTeam, ['role' => 'member']); + $this->user->teams()->attach($this->sessionTeam, ['role' => 'owner']); + + $this->project = Project::factory()->create(['team_id' => $this->resourceTeam->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create(['environment_id' => $this->environment->id]); + + SharedEnvironmentVariable::create(['key' => 'RESOURCE_TEAM_VAR', 'value' => 'a', 'type' => 'team', 'team_id' => $this->resourceTeam->id]); + SharedEnvironmentVariable::create(['key' => 'SESSION_TEAM_VAR', 'value' => 'b', 'type' => 'team', 'team_id' => $this->sessionTeam->id]); + SharedEnvironmentVariable::create(['key' => 'RESOURCE_PROJECT_VAR', 'value' => 'c', 'type' => 'project', 'team_id' => $this->resourceTeam->id, 'project_id' => $this->project->id]); + + $this->parameters = [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'application_uuid' => $this->application->uuid, + ]; + + $this->actingAs($this->user); + session(['currentTeam' => $this->sessionTeam]); +}); + +test('add suggests shared variables of the resource team when the session team differs', function () { + $component = Livewire::test(Add::class, ['resource' => $this->application]) + ->set('parameters', $this->parameters); + + $suggestions = $component->instance()->availableSharedVariables(); + + expect($suggestions['team'])->toBe(['RESOURCE_TEAM_VAR']) + ->and($suggestions['project'])->toBe(['RESOURCE_PROJECT_VAR']); +}); + +test('show suggests shared variables of the resource team when the session team differs', function () { + $env = EnvironmentVariable::create([ + 'key' => 'APP_VAR', + 'value' => 'x', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $component = Livewire::test(Show::class, ['env' => $env, 'type' => 'application']) + ->set('parameters', $this->parameters); + + $suggestions = $component->instance()->availableSharedVariables(); + + expect($suggestions['team'])->toBe(['RESOURCE_TEAM_VAR']) + ->and($suggestions['project'])->toBe(['RESOURCE_PROJECT_VAR']); +}); + +test('add without a resource keeps suggesting the session team variables', function () { + $component = Livewire::test(Add::class, ['shared' => true]); + + expect($component->instance()->availableSharedVariables()['team'])->toBe(['SESSION_TEAM_VAR']); +}); diff --git a/tests/Feature/Livewire/ConfigurationCheckerTest.php b/tests/Feature/Livewire/ConfigurationCheckerTest.php index 4adf86fff0..d721aaf25e 100644 --- a/tests/Feature/Livewire/ConfigurationCheckerTest.php +++ b/tests/Feature/Livewire/ConfigurationCheckerTest.php @@ -344,3 +344,61 @@ it('keeps compose changes visible to an owner in the change list', function () { expect(json_encode($component->get('configurationDiff')))->toContain('new-compose-value'); }); + +it('redacts environment values for a team member whose session is on a team they own', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $ownTeam = Team::factory()->create(); + $ownTeam->members()->attach($member->id, ['role' => 'owner']); + $this->actingAs($member); + session(['currentTeam' => $ownTeam]); + + $application = configurationCheckerApplication($this->environment); + markConfigurationCheckerApplicationDeployed($application); + EnvironmentVariable::create([ + 'key' => 'API_TOKEN', + 'value' => 'new-secret', + 'is_buildtime' => false, + 'is_runtime' => true, + 'is_preview' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + + $component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]) + ->assertDontSee('new-secret'); + + $envChange = collect(data_get($component->get('configurationDiff'), 'changes', [])) + ->first(fn (array $change): bool => str_contains((string) data_get($change, 'key'), 'API_TOKEN') + || str_contains((string) data_get($change, 'label'), 'API_TOKEN')); + + expect($envChange)->not->toBeNull() + ->and(data_get($envChange, 'new_display_value'))->toBe('••••••••') + ->and(data_get($envChange, 'new_full_value'))->toBeNull(); +}); + +it('shows environment values to a resource team owner whose session is on a team where they are a member', function () { + $otherTeam = Team::factory()->create(); + $otherTeam->members()->attach($this->user->id, ['role' => 'member']); + session(['currentTeam' => $otherTeam]); + + $application = configurationCheckerApplication($this->environment); + markConfigurationCheckerApplicationDeployed($application); + EnvironmentVariable::create([ + 'key' => 'API_TOKEN', + 'value' => 'new-secret', + 'is_buildtime' => false, + 'is_runtime' => true, + 'is_preview' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + + $component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]); + + $envChange = collect(data_get($component->get('configurationDiff'), 'changes', [])) + ->first(fn (array $change): bool => str_contains((string) data_get($change, 'key'), 'API_TOKEN') + || str_contains((string) data_get($change, 'label'), 'API_TOKEN')); + + expect(data_get($envChange, 'new_display_value'))->not->toBe('••••••••'); +}); diff --git a/tests/Feature/ManagedDnsRecordEditReleaseTest.php b/tests/Feature/ManagedDnsRecordEditReleaseTest.php index 7beff03e04..72a8b8000d 100644 --- a/tests/Feature/ManagedDnsRecordEditReleaseTest.php +++ b/tests/Feature/ManagedDnsRecordEditReleaseTest.php @@ -6,7 +6,10 @@ use App\Jobs\ConfigureDnsRecordJob; use App\Jobs\ReleaseManagedDnsRecordsJob; use App\Jobs\ReleaseRemovedDnsHostnamesJob; use App\Livewire\Project\Application\Domains; +use App\Livewire\Project\Application\General; +use App\Livewire\Project\Application\PreviewDomains; use App\Livewire\Project\Service\Domains as ServiceDomains; +use App\Livewire\Project\Service\Index as ServiceIndex; use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\DnsProviderZone; @@ -614,3 +617,64 @@ describe('hostname lock', function () { $heldLock->release(); }); }); + +describe('domain edits after the session team changes', function () { + beforeEach(function () { + Queue::fake([CheckDomainDnsJob::class, ReleaseRemovedDnsHostnamesJob::class]); + fakeCloudflareDns(); + $this->sessionTeam = Team::factory()->create(); + $this->sessionTeam->members()->attach($this->user->id, ['role' => 'owner']); + $this->user->unsetRelation('teams'); + }); + + function assertReleaseQueuedForTeam(int $teamId, string $resourceType, int $resourceId): void + { + Queue::assertPushed(ReleaseRemovedDnsHostnamesJob::class, fn (ReleaseRemovedDnsHostnamesJob $job): bool => $job->teamId === $teamId + && $job->resourceType === $resourceType + && $job->resourceId === $resourceId + && $job->hostnames === ['app.example.com']); + } + + test('the application general form queues the release for the resource team', function () { + $application = createDnsTestApplication($this, 'https://app.example.com'); + app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); + + $component = Livewire::test(General::class, ['application' => $application]); + session(['currentTeam' => $this->sessionTeam]); + $component->set('fqdn', 'https://new.example.com')->call('submit')->assertHasNoErrors(); + + expect($application->fresh()->fqdn)->toBe('https://new.example.com'); + assertReleaseQueuedForTeam($this->team->id, $application->getMorphClass(), $application->id); + }); + + test('the preview domain form queues the release for the resource team', function () { + $application = createDnsTestApplication($this, 'https://main.example.com'); + $preview = ApplicationPreview::create([ + 'application_id' => $application->id, 'pull_request_id' => 7, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/7', 'fqdn' => 'https://app.example.com', + ]); + app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $preview); + + $component = Livewire::test(PreviewDomains::class, ['preview' => $preview]); + session(['currentTeam' => $this->sessionTeam]); + $component->call('startEdit', 0) + ->set('editingDomainParts.host', 'new.example.com') + ->call('updateDomain') + ->assertHasNoErrors(); + + expect($preview->fresh()->fqdn)->toContain('new.example.com'); + assertReleaseQueuedForTeam($this->team->id, $preview->getMorphClass(), $preview->id); + }); + + test('the service application form queues the release for the resource team', function () { + $serviceApplication = editReleaseService($this, 'https://app.example.com'); + app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $serviceApplication); + + $component = Livewire::test(ServiceIndex::class, ['serviceApplication' => $serviceApplication->fresh()]); + session(['currentTeam' => $this->sessionTeam]); + $component->set('fqdn', 'https://new.example.com')->call('submitApplication')->assertHasNoErrors(); + + expect($serviceApplication->fresh()->fqdn)->toBe('https://new.example.com'); + assertReleaseQueuedForTeam($this->team->id, $serviceApplication->getMorphClass(), $serviceApplication->id); + }); +}); diff --git a/tests/Feature/PersistentStorageVolumesLayoutTest.php b/tests/Feature/PersistentStorageVolumesLayoutTest.php index 500a602265..cdbdbab546 100644 --- a/tests/Feature/PersistentStorageVolumesLayoutTest.php +++ b/tests/Feature/PersistentStorageVolumesLayoutTest.php @@ -191,7 +191,7 @@ it('does not show a source path or removal action for a named volume', function Livewire::test(All::class, ['resource' => $application]) ->assertDontSee('Directory mount') - ->assertSee('Volume mount') + ->assertDontSee('Volume mount') ->assertDontSee('Remove Source Path'); }); diff --git a/tests/Feature/ResourceTeamAfterTeamSwitchTest.php b/tests/Feature/ResourceTeamAfterTeamSwitchTest.php new file mode 100644 index 0000000000..6a434a035a --- /dev/null +++ b/tests/Feature/ResourceTeamAfterTeamSwitchTest.php @@ -0,0 +1,198 @@ + 'file']); + Server::flushIdentityMap(); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true])); + + $this->teamA = Team::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->teamA, ['role' => 'owner']); + $this->user->teams()->attach($this->teamB, ['role' => 'owner']); + + $server = Server::factory()->create(['team_id' => $this->teamA->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->teamA->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->service = Service::factory()->create([ + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'environment_id' => $environment->id, + ]); + $this->serviceDatabase = ServiceDatabase::create([ + 'service_id' => $this->service->id, + 'name' => 'team-switch-db', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->teamA]); +}); + +function createS3ForTeamSwitchTest(Team $team, string $name): S3Storage +{ + return S3Storage::create([ + 'name' => $name, + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => $name.'-bucket', + 'endpoint' => 'https://s3.example.com', + 'is_usable' => true, + 'team_id' => $team->id, + ]); +} + +test('tags of the session team are not listed or attached after a team switch', function () { + $foreignTag = Tag::create(['name' => 'production', 'team_id' => $this->teamB->id]); + $component = Livewire::test(Tags::class, ['resource' => $this->application]); + session(['currentTeam' => $this->teamB]); + + $component->call('loadTags') + ->assertSet('tags', fn ($tags) => $tags->isEmpty()) + ->set('newTags', 'production') + ->call('submit'); + Livewire::test(Tags::class, ['resource' => $this->application]) + ->call('addTag', (string) $foreignTag->id) + ->assertNotFound(); + + $attached = $this->application->tags()->get(); + expect($attached)->toHaveCount(1) + ->and($attached->first()->id)->not->toBe($foreignTag->id) + ->and($attached->first()->team_id)->toBe($this->teamA->id); +}); + +test('deploy by tag skips resources of another team', function () { + Queue::fake(); + $tag = Tag::create(['name' => 'production', 'team_id' => $this->teamB->id]); + $this->application->tags()->attach($tag->id); + + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'team-b-deploy', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['deploy'], + 'team_id' => $this->teamB->id, + ]); + $this->app['auth']->forgetGuards(); + + $this->withHeaders(['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]) + ->postJson('/api/v1/deploy', ['tag' => 'production']) + ->assertOk() + ->assertJsonMissingPath('details'); + + expect(ApplicationDeploymentQueue::count())->toBe(0); + Queue::assertNotPushed(ApplicationDeploymentJob::class); +}); + +test('scheduled task belongs to the resource team after a team switch', function () { + $component = Livewire::test(AddScheduledTask::class, [ + 'id' => (string) $this->application->id, + 'type' => 'application', + 'containerNames' => collect(), + ]); + session(['currentTeam' => $this->teamB]); + + $component->set('name', 'team-switch-task') + ->set('command', 'echo ok') + ->set('frequency', '0 0 * * *') + ->call('submit') + ->assertHasNoErrors(); + + expect(ScheduledTask::query()->sole()->team_id)->toBe($this->teamA->id); +}); + +test('service backup schedule offers only S3 storages of the service team', function () { + $ownS3 = createS3ForTeamSwitchTest($this->teamA, 'team-a-s3'); + createS3ForTeamSwitchTest($this->teamB, 'team-b-s3'); + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_id' => $ownS3->id, + 'database_id' => $this->serviceDatabase->id, + 'database_type' => $this->serviceDatabase->getMorphClass(), + ]); + $component = Livewire::test(ServiceVolumeBackupIndex::class, ['service' => $this->service]); + session(['currentTeam' => $this->teamB]); + + $component->call('openSchedule', $backup->uuid) + ->assertSet('s3s', fn ($s3s) => $s3s->pluck('id')->all() === [$ownS3->id]) + ->assertSee('S3 storage: team-a-s3 (bucket: team-a-s3-bucket)'); +}); + +test('backup edit keeps the S3 storage of the backup team when given a foreign list', function () { + $ownS3 = createS3ForTeamSwitchTest($this->teamA, 'team-a-s3'); + $foreignS3 = createS3ForTeamSwitchTest($this->teamB, 'team-b-s3'); + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => $ownS3->id, + 'database_id' => $this->serviceDatabase->id, + 'database_type' => $this->serviceDatabase->getMorphClass(), + ]); + + $component = Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->teamB->s3s]) + ->call('instantSave'); + expect($backup->fresh()->s3_storage_id)->toBe($ownS3->id) + ->and($backup->fresh()->save_s3)->toBeTruthy(); + + $component->set('s3StorageId', $foreignS3->id)->call('instantSave'); + expect($backup->fresh()->s3_storage_id)->toBe($ownS3->id); +}); + +test('service backup executions label the S3 storage of the service team after a team switch', function () { + $ownS3 = createS3ForTeamSwitchTest($this->teamA, 'team-a-s3'); + $schedule = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_id' => $ownS3->id, + 'database_id' => $this->serviceDatabase->id, + 'database_type' => $this->serviceDatabase->getMorphClass(), + ]); + ScheduledDatabaseBackupExecution::create(['scheduled_database_backup_id' => $schedule->id, 'status' => 'success']); + $component = Livewire::test(BackupExecutions::class, ['service' => $this->service]); + session(['currentTeam' => $this->teamB]); + + $component->call('$refresh') + ->assertViewHas('executions', fn ($executions) => str_contains($executions->first()['s3_tooltip'], 'team-a-s3')); +}); diff --git a/tests/Feature/ServerDestinationsPageTest.php b/tests/Feature/ServerDestinationsPageTest.php index bc89312d12..2d7fb19b7a 100644 --- a/tests/Feature/ServerDestinationsPageTest.php +++ b/tests/Feature/ServerDestinationsPageTest.php @@ -108,3 +108,44 @@ test('adding a discovered swarm destination stores the selected network name', f expect(SwarmDocker::where('server_id', $server->id)->where('network', 'customer-network')->exists())->toBeTrue(); }); + +/** + * Makes the user a member of a new team that owns a usable server, with the session on that team. + */ +function destinationsMemberTeamServer(User $user, bool $swarm = false): Server +{ + $memberTeam = Team::factory()->create(); + $user->teams()->attach($memberTeam, ['role' => 'member']); + $user->load('teams'); + session(['currentTeam' => $memberTeam]); + + $server = Server::factory()->create(['team_id' => $memberTeam->id]); + $server->settings()->update(['is_reachable' => true, 'is_usable' => true, 'is_swarm_manager' => $swarm]); + + return $server; +} + +test('member cannot add a destination on the owning team server after switching to an owned team', function (bool $swarm) { + $server = destinationsMemberTeamServer($this->user, $swarm); + + $component = Livewire::test(Destinations::class, ['server_uuid' => $server->uuid]); + session(['currentTeam' => $this->team]); + + $component->call('add', 'customer-network')->assertForbidden(); + + expect(StandaloneDocker::where('network', 'customer-network')->exists())->toBeFalse() + ->and(SwarmDocker::where('network', 'customer-network')->exists())->toBeFalse(); +})->with(['standalone' => false, 'swarm' => true]); + +test('member cannot create a docker network on the owning team server after switching to an owned team', function () { + $server = destinationsMemberTeamServer($this->user); + + $component = Livewire::test(Docker::class, ['server_id' => (string) $server->id]); + session(['currentTeam' => $this->team]); + + $component->set('network', 'customer-network') + ->call('submit') + ->assertDispatched('error'); + + expect(StandaloneDocker::where('network', 'customer-network')->exists())->toBeFalse(); +}); diff --git a/tests/Feature/ServerPrivateKeyDropdownTest.php b/tests/Feature/ServerPrivateKeyDropdownTest.php index d42e554137..8951491a97 100644 --- a/tests/Feature/ServerPrivateKeyDropdownTest.php +++ b/tests/Feature/ServerPrivateKeyDropdownTest.php @@ -8,6 +8,7 @@ use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Js; use Illuminate\Support\Once; @@ -86,3 +87,52 @@ test('server private key cards include a copy public key button', function () { ->assertSee('Alternative SSH Key') ->assertSeeHtml('copyPublicKeyToClipboard('.Js::from($alternativeKey->public_key)->toHtml().')'); }); + +describe('after the user switches the session team', function () { + beforeEach(function () { + Process::fake(); + Storage::fake('ssh-mux'); + + $this->otherTeam = Team::factory()->create(); + $this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']); + $this->otherTeamKey = PrivateKey::factory()->create([ + 'team_id' => $this->otherTeam->id, + 'private_key' => PrivateKey::generateNewKeyPair('ed25519')['private_key'], + ]); + $this->sameTeamKey = PrivateKey::factory()->create([ + 'team_id' => $this->team->id, + 'private_key' => PrivateKey::generateNewKeyPair('ed25519')['private_key'], + ]); + + $this->component = Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]); + session(['currentTeam' => $this->otherTeam]); + Once::flush(); + }); + + test('lists only keys of the server team', function () { + $keyIds = $this->component->call('generatePrivateKey', 'ed25519')->get('privateKeys')->pluck('id'); + + expect($keyIds)->toContain($this->sameTeamKey->id) + ->not->toContain($this->otherTeamKey->id); + }); + + test('generates the key in the server team', function () { + $this->component->call('generatePrivateKey', 'ed25519'); + + expect(PrivateKey::query()->latest('id')->firstOrFail()->team_id)->toBe($this->team->id); + }); + + test('rejects a key of the session team', function () { + $this->component->call('setPrivateKey', $this->otherTeamKey->id) + ->assertDispatched('error', 'You are not allowed to use this private key.'); + + expect($this->server->fresh()->private_key_id)->toBe($this->currentPrivateKey->id); + }); + + test('accepts a key of the server team', function () { + $this->component->call('setPrivateKey', $this->sameTeamKey->id) + ->assertDispatched('success'); + + expect($this->server->fresh()->private_key_id)->toBe($this->sameTeamKey->id); + }); +}); diff --git a/tests/Feature/SharedVariableDevViewTest.php b/tests/Feature/SharedVariableDevViewTest.php index 9fca94c146..9640193f13 100644 --- a/tests/Feature/SharedVariableDevViewTest.php +++ b/tests/Feature/SharedVariableDevViewTest.php @@ -10,6 +10,7 @@ use App\Models\SharedEnvironmentVariable; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Cache; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -228,3 +229,61 @@ test('server shared variable values are only visible to admins', function () { expect(json_encode($component->snapshot).$component->html())->not->toContain('stored-server-variable-value'); }); + +/** + * Mounts as a member of $this->team, then switches the session to a team the user owns. + */ +function sharedVarsSwitchToOwnedTeam(User $user): Team +{ + $ownTeam = Team::factory()->create(); + $user->teams()->attach($ownTeam, ['role' => 'owner']); + $user->load('teams'); + session(['currentTeam' => $ownTeam]); + Cache::flush(); + + return $ownTeam; +} + +dataset('shared variable scopes', [ + 'project' => fn () => [App\Livewire\SharedVariables\Project\Show::class, ['project_uuid' => test()->project->uuid], ['type' => 'project', 'project_id' => test()->project->id]], + 'environment' => fn () => [Show::class, ['project_uuid' => test()->project->uuid, 'environment_uuid' => test()->environment->uuid], ['type' => 'environment', 'environment_id' => test()->environment->id, 'project_id' => test()->project->id]], + 'server' => function () { + $server = Server::factory()->create(['team_id' => test()->team->id]); + + return [App\Livewire\SharedVariables\Server\Show::class, ['server_uuid' => $server->uuid], ['type' => 'server', 'server_id' => $server->id]]; + }, + 'team' => fn () => [Index::class, [], ['type' => 'team']], +]); + +test('shared variable values stay hidden from a member of the owning team after switching to an owned team', function (array $scope) { + [$component, $params, $attributes] = $scope; + SharedEnvironmentVariable::create([...$attributes, 'key' => 'ZZ_SECRET', 'value' => 'owning-team-secret', 'team_id' => $this->team->id]); + $this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + $this->user->refresh(); + + $livewire = Livewire::test($component, $params) + ->assertSet('variables', 'ZZ_SECRET=(Hidden, only admins can view)'); + + $ownTeam = sharedVarsSwitchToOwnedTeam($this->user); + expect(currentTeam()->id)->toBe($ownTeam->id); + + $livewire->call('switch') + ->call('refreshEnvs') + ->assertSet('variables', 'ZZ_SECRET=(Hidden, only admins can view)'); + + expect(json_encode($livewire->snapshot).$livewire->html())->not->toContain('owning-team-secret'); +})->with('shared variable scopes'); + +test('shared variables created after switching teams belong to the owning team', function (array $scope) { + [$component, $params] = $scope; + + $livewire = Livewire::test($component, $params); + sharedVarsSwitchToOwnedTeam($this->user); + + $livewire->call('saveKey', ['key' => 'ZZ_SAVED', 'value' => 'one', 'is_multiline' => false, 'is_literal' => false]) + ->set('variables', "ZZ_SAVED=one\nZZ_BULK=two") + ->call('submit'); + + expect(SharedEnvironmentVariable::whereIn('key', ['ZZ_SAVED', 'ZZ_BULK'])->pluck('team_id', 'key')->sortKeys()->all()) + ->toBe(['ZZ_BULK' => $this->team->id, 'ZZ_SAVED' => $this->team->id]); +})->with('shared variable scopes'); diff --git a/tests/Feature/StartClickhouseVolumesTest.php b/tests/Feature/StartClickhouseVolumesTest.php new file mode 100644 index 0000000000..faa13a0944 --- /dev/null +++ b/tests/Feature/StartClickhouseVolumesTest.php @@ -0,0 +1,176 @@ + 0]); + config(['app.maintenance.store' => 'array', 'constants.ssh.mux_enabled' => false]); + Process::fake(); + Queue::fake(); + + $team = Team::factory()->create(); + $this->team = $team; + $this->owner = User::factory()->create(); + $this->member = User::factory()->create(); + $team->members()->attach($this->owner->id, ['role' => 'owner']); + $team->members()->attach($this->member->id, ['role' => 'member']); + $this->server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); +}); + +/** + * @return array + */ +function startedClickhouseCompose(): array +{ + $compose = null; + Process::assertRan(function ($process) use (&$compose) { + if (preg_match("/echo '([A-Za-z0-9+\/=]+)' \| base64 -d \| (?:sudo )?tee \S+\/docker-compose\.yml/", $process->command, $matches)) { + $compose = Yaml::parse(base64_decode($matches[1])); + } + + return true; + }); + + return $compose; +} + +it('keeps the data volume when a file mount is added', function () { + $database = create_standalone_clickhouse($this->environment->id, $this->destination); + LocalFileVolume::create([ + 'fs_path' => database_configuration_dir().'/'.$database->uuid.'/etc/clickhouse-server/users.d/extra.xml', + 'mount_path' => '/etc/clickhouse-server/users.d/extra.xml', + 'content' => '', + 'is_directory' => false, + 'resource_id' => $database->id, + 'resource_type' => $database->getMorphClass(), + ]); + + StartClickhouse::run($database->fresh(), activity()->log('Starting database')); + + expect(startedClickhouseCompose()['services'][$database->uuid]['volumes'])->toBe([ + "clickhouse-data-{$database->uuid}:/var/lib/clickhouse", + database_configuration_dir()."/{$database->uuid}/etc/clickhouse-server/users.d/extra.xml:/etc/clickhouse-server/users.d/extra.xml", + ]); +}); + +/** + * Fake the remote `docker inspect` so the container's data directory is mounted from $volumeName. + */ +function fakeClickhouseDataMount(string $volumeName): void +{ + Process::fake(fn ($process) => Process::result( + output: str_contains($process->command, 'docker inspect') ? "volume {$volumeName}" : 'OK', + )); +} + +function ranRemoteCommandContaining(string $needle): bool +{ + $ran = false; + Process::assertRan(function ($process) use ($needle, &$ran) { + $ran = $ran || str_contains($process->command, $needle); + + return true; + }); + + return $ran; +} + +it('blocks start and restart while the current data is in an unnamed volume', function () { + $database = create_standalone_clickhouse($this->environment->id, $this->destination); + fakeClickhouseDataMount('3f1c0ffee'); + + expect(StartDatabase::run($database->fresh()))->toContain('unnamed Docker volume 3f1c0ffee') + ->and(RestartDatabase::run($database->fresh()))->toContain('unnamed Docker volume 3f1c0ffee') + ->and(ranRemoteCommandContaining("docker rm -f {$database->uuid}"))->toBeFalse(); + Queue::assertNotPushed(DatabaseStartJob::class); +}); + +it('starts a database that uses its data volume', function () { + $database = create_standalone_clickhouse($this->environment->id, $this->destination); + fakeClickhouseDataMount("clickhouse-data-{$database->uuid}"); + + expect(StartDatabase::run($database->fresh()))->toBeInstanceOf(Activity::class); + Queue::assertPushed(DatabaseStartJob::class); +}); + +it('keeps the container on stop so the unnamed volume stays linked, except on delete', function (bool $keepAnonymousDataVolume, bool $removesContainer) { + $database = create_standalone_clickhouse($this->environment->id, $this->destination); + fakeClickhouseDataMount('3f1c0ffee'); + + StopDatabase::run($database->fresh(), dockerCleanup: false, keepAnonymousDataVolume: $keepAnonymousDataVolume); + + expect(ranRemoteCommandContaining("docker rm -f {$database->uuid}"))->toBe($removesContainer); +})->with([ + 'stop' => [true, false], + 'delete' => [false, true], +]); + +it('lets an admin keep the current data volume and warns about it', function () { + $database = create_standalone_clickhouse($this->environment->id, $this->destination); + fakeClickhouseDataMount('3f1c0ffee'); + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::test(ClickhouseGeneral::class, ['database' => $database->fresh()]) + ->assertSet('anonymousDataVolume', '3f1c0ffee') + ->assertSee('Keep current data') + ->call('keepCurrentDataVolume') + ->assertSet('anonymousDataVolume', null); + + expect($database->persistentStorages()->where('mount_path', StandaloneClickhouse::DATA_DIRECTORY)->value('name'))->toBe('3f1c0ffee') + ->and(StartDatabase::prerequisiteError($database->fresh()))->toBeNull(); +}); + +it('does not let a member keep the current data volume', function () { + $database = create_standalone_clickhouse($this->environment->id, $this->destination); + fakeClickhouseDataMount('3f1c0ffee'); + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ClickhouseGeneral::class, ['database' => $database->fresh()]) + ->assertSee('Current data is not in the data volume') + ->call('keepCurrentDataVolume'); + + expect($database->persistentStorages()->where('mount_path', StandaloneClickhouse::DATA_DIRECTORY)->value('name')) + ->toBe("clickhouse-data-{$database->uuid}"); +}); + +it('reads the data mount with sudo on a non-root server', function () { + $this->server->update(['user' => 'cooluser']); + $database = create_standalone_clickhouse($this->environment->id, $this->destination); + fakeClickhouseDataMount('3f1c0ffee'); + + expect($database->fresh()->anonymousDataVolume())->toBe('3f1c0ffee') + ->and(ranRemoteCommandContaining("sudo docker inspect --format '{{range .Mounts}}{{if eq .Destination \"/var/lib/clickhouse\"}}{{.Type}} {{.Name}}{{end}}{{end}}' '{$database->uuid}' 2>/dev/null || sudo true"))->toBeTrue(); +}); diff --git a/tests/Feature/StorageApiTest.php b/tests/Feature/StorageApiTest.php index 2b0774d8ff..652fa88ffa 100644 --- a/tests/Feature/StorageApiTest.php +++ b/tests/Feature/StorageApiTest.php @@ -649,6 +649,26 @@ describe('DELETE /api/v1/databases/{uuid}/storages/{storage_uuid}', function () $response->assertStatus(200); expect(LocalPersistentVolume::find($vol->id))->toBeNull(); }); + + test('deletes a file storage', function () { + $db = createTestDatabase($this); + + $vol = LocalFileVolume::create([ + 'fs_path' => database_configuration_dir().'/'.$db->uuid.'/postgres/postgresql.conf', + 'mount_path' => '/postgres/postgresql.conf', + 'content' => 'listen_addresses = "*"', + 'is_directory' => false, + 'resource_id' => $db->id, + 'resource_type' => $db->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->deleteJson("/api/v1/databases/{$db->uuid}/storages/{$vol->uuid}"); + + $response->assertStatus(200); + expect(LocalFileVolume::find($vol->id))->toBeNull(); + }); }); test('rejects host paths when creating persistent storage through the API', function (string $resourceType) { diff --git a/tests/Feature/TagQuickAddTest.php b/tests/Feature/TagQuickAddTest.php index 1289268aef..8ab856035c 100644 --- a/tests/Feature/TagQuickAddTest.php +++ b/tests/Feature/TagQuickAddTest.php @@ -79,3 +79,15 @@ test('member cannot create a tag through resource settings', function () { ->assertDispatched('error'); expect(Tag::where('name', 'member-tag')->exists())->toBeFalse(); }); + +test('typing the name of an existing team tag attaches that tag', function () { + $existing = Tag::create(['name' => 'existing-tag', 'team_id' => $this->team->id]); + + Livewire::test(Tags::class, ['resource' => $this->application]) + ->set('newTags', 'existing-tag') + ->call('submit') + ->assertNotDispatched('error'); + + expect($this->application->tags()->pluck('tags.id')->all())->toBe([$existing->id]) + ->and(Tag::where('name', 'existing-tag')->count())->toBe(1); +});