diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index 4771e4195d..cc91f865c9 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -268,6 +268,8 @@ class CloneMe extends Component ])->fill($payload); $newEnvironmentVariable->save(); } + + $database->cloneSecretManagerLinkTo($newDatabase); } foreach ($services as $service) { @@ -317,6 +319,8 @@ class CloneMe extends Component $newEnvironmentVariable->save(); } + $service->cloneSecretManagerLinkTo($newService); + foreach ($newService->applications() as $application) { $application->fill([ 'status' => 'exited', diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 61f539cde6..6f9bc0265e 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -243,6 +243,8 @@ class ResourceOperations extends Component $newEnvironmentVariable->save(); } + $this->resource->cloneSecretManagerLinkTo($new_resource); + $route = route('project.database.configuration', [ 'project_uuid' => $new_environment->project->uuid, 'environment_uuid' => $new_environment->uuid, @@ -299,6 +301,8 @@ class ResourceOperations extends Component $newEnvironmentVariable->save(); } + $this->resource->cloneSecretManagerLinkTo($new_resource); + foreach ($new_resource->applications() as $application) { $application->fill([ 'status' => 'exited', diff --git a/app/Services/ServerTransfer/ServerTransferExporter.php b/app/Services/ServerTransfer/ServerTransferExporter.php index d169a1bc06..8db6e5afd1 100644 --- a/app/Services/ServerTransfer/ServerTransferExporter.php +++ b/app/Services/ServerTransfer/ServerTransferExporter.php @@ -106,6 +106,9 @@ class ServerTransferExporter $sourceInstanceUrl = rtrim((string) (instanceSettings()->fqdn ?: config('app.url')), '/'); $warnings = $this->buildWarnings($applications, $dependencies, $sourceInstanceUrl); + if ($secretManagerWarning = $this->secretManagerWarning($applications->concat($databases)->concat($services))) { + $warnings[] = $secretManagerWarning; + } $payload = ServerTransferBundle::wrap([ 'source_instance' => [ @@ -331,6 +334,27 @@ class ServerTransferExporter return $warnings; } + /** + * Secret manager links point to integration tokens of this instance, which are not exported. + * + * @param Collection $resources + */ + private function secretManagerWarning(Collection $resources): ?string + { + $linkedNames = $resources + ->filter(fn (Model $resource) => method_exists($resource, 'secretManagerLink') && $resource->secretManagerLink()->exists()) + ->pluck('name') + ->unique() + ->values(); + + if ($linkedNames->isEmpty()) { + return null; + } + + return 'Some resources load environment variables from a secret manager: '.$linkedNames->implode(', ').'. ' + .'Secret manager tokens and links are not exported. After import, add the integration token on the target instance and link the secret manager source again, or deployments of these resources fail.'; + } + /** * @return array */ diff --git a/app/Traits/HasSecretManager.php b/app/Traits/HasSecretManager.php index a788ae823e..230232c503 100644 --- a/app/Traits/HasSecretManager.php +++ b/app/Traits/HasSecretManager.php @@ -5,6 +5,7 @@ namespace App\Traits; use App\Models\EnvironmentVariable; use App\Models\SecretManagerLink; use App\Support\RemoteSecretReferences; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphOne; use RuntimeException; @@ -26,6 +27,25 @@ trait HasSecretManager return $this->morphOne(SecretManagerLink::class, 'resourceable'); } + /** + * Give a cloned resource the same secret manager source. The token is team-scoped, so a clone in another team gets no link. + */ + public function cloneSecretManagerLinkTo(Model $target): void + { + $link = $this->secretManagerLink()->with('integrationToken')->first(); + // fresh(): a replicated model keeps the relations of its source, such as the old environment. + $targetTeamId = $target->fresh()?->team()?->id; + + if (! $link || $targetTeamId === null || (int) $link->integrationToken?->team_id !== (int) $targetTeamId) { + return; + } + + $target->secretManagerLink()->create([ + 'integration_token_id' => $link->integration_token_id, + 'settings' => $link->settings, + ]); + } + public function resolveSecretManagerEnvironmentVariable(EnvironmentVariable $environmentVariable): ?string { $value = $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable); diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index e334bcd682..a3a5417b4e 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -401,6 +401,8 @@ function clone_application(Application $source, $destination, array $overrides = }); } + $source->cloneSecretManagerLinkTo($newApplication); + // Clone preview environment variables $previewEnvironmentVariables = $source->environment_variables_preview()->get(); foreach ($previewEnvironmentVariables as $previewEnvironmentVariable) { diff --git a/tests/Feature/SecretManagers/CloneSecretManagerLinkTest.php b/tests/Feature/SecretManagers/CloneSecretManagerLinkTest.php new file mode 100644 index 0000000000..da7e469251 --- /dev/null +++ b/tests/Feature/SecretManagers/CloneSecretManagerLinkTest.php @@ -0,0 +1,136 @@ +withoutVite(); + InstanceSettings::forceCreate(['id' => 0]); + Server::flushIdentityMap(); + + $this->user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = $this->server->standaloneDockers()->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $this->application->settings->fill(['is_container_label_readonly_enabled' => false])->save(); + + $this->token = IntegrationToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'vault', + 'capabilities' => ['secrets'], + 'metadata' => ['base_url' => 'https://vault.example.com'], + ]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +function cloneLinkTestDatabase(Environment $environment, $destination): StandalonePostgresql +{ + return StandalonePostgresql::create([ + 'name' => 'pg-linked', + 'uuid' => new_public_id(), + 'postgres_password' => 'secret', + 'postgres_user' => 'postgres', + 'postgres_db' => 'postgres', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'status' => 'exited', + ]); +} + +test('cloning an application copies its secret manager link', function () { + $this->application->secretManagerLink()->create([ + 'integration_token_id' => $this->token->id, + 'settings' => ['mount' => 'secret', 'path' => 'apps/demo'], + ]); + + $clone = clone_application($this->application, $this->destination, [ + 'environment_id' => $this->environment->id, + ]); + + $link = $clone->secretManagerLink()->first(); + expect($link)->not->toBeNull() + ->and($link->integration_token_id)->toBe($this->token->id) + ->and($link->settings)->toBe(['mount' => 'secret', 'path' => 'apps/demo']) + ->and($this->application->secretManagerLink()->exists())->toBeTrue() + ->and(SecretManagerLink::count())->toBe(2); +}); + +test('cloning a database copies its secret manager link', function () { + $database = cloneLinkTestDatabase($this->environment, $this->destination); + $database->secretManagerLink()->create([ + 'integration_token_id' => $this->token->id, + 'settings' => ['mount' => 'secret', 'path' => 'db'], + ]); + + Livewire::test(ResourceOperations::class, ['resource' => $database]) + ->call('cloneTo', $this->destination->uuid) + ->assertRedirect(); + + $clone = StandalonePostgresql::whereKeyNot($database->id)->sole(); + expect($clone->secretManagerLink()->first()?->integration_token_id)->toBe($this->token->id) + ->and($clone->secretManagerLink()->first()?->settings)->toBe(['mount' => 'secret', 'path' => 'db']); +}); + +test('cloning an environment copies the secret manager links of its resources', function () { + $this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]); + $database = cloneLinkTestDatabase($this->environment, $this->destination); + $database->secretManagerLink()->create(['integration_token_id' => $this->token->id]); + + Livewire::test(CloneMe::class, [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + ]) + ->set('selectedDestination', $this->destination->uuid) + ->set('newName', 'linked-clone') + ->call('clone', 'environment') + ->assertNotDispatched('error'); + + $clonedEnvironment = $this->project->environments()->where('name', 'linked-clone')->sole(); + $clonedApplication = $clonedEnvironment->applications()->sole(); + $clonedDatabase = StandalonePostgresql::where('environment_id', $clonedEnvironment->id)->sole(); + + expect($clonedApplication->secretManagerLink()->first()?->integration_token_id)->toBe($this->token->id) + ->and($clonedDatabase->secretManagerLink()->first()?->integration_token_id)->toBe($this->token->id); +}); + +test('cloning does not copy a secret manager link whose token belongs to another team', function () { + $foreignToken = IntegrationToken::factory()->create([ + 'team_id' => Team::factory()->create()->id, + 'provider' => 'vault', + 'capabilities' => ['secrets'], + ]); + $this->application->secretManagerLink()->create(['integration_token_id' => $foreignToken->id]); + + $clone = clone_application($this->application, $this->destination, [ + 'environment_id' => $this->environment->id, + ]); + + expect($clone->secretManagerLink()->exists())->toBeFalse(); +}); diff --git a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php index 6f9426eef5..d5f1059768 100644 --- a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php +++ b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php @@ -7,6 +7,7 @@ use App\Models\EnvironmentVariable; use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\InstanceSettings; +use App\Models\IntegrationToken; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; use App\Models\PrivateKey; @@ -980,3 +981,35 @@ test('transfer keeps the link between an application volume and its sqlite datab ->and($importedSqlite->hasConnectedApplications())->toBeTrue() ->and($importedSqlite->persistentStorages()->sole()->isSharedWithAnotherResource())->toBeTrue(); }); + +test('export warns about secret manager links without exporting the integration token', function () { + $token = IntegrationToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'vault', + 'token' => 'vault-root-token', + 'capabilities' => ['secrets'], + ]); + $this->application->secretManagerLink()->create(['integration_token_id' => $token->id]); + $this->database->secretManagerLink()->create(['integration_token_id' => $token->id]); + + $bundle = $this->exporter->export($this->server); + $warning = collect($bundle['warnings'])->first(fn (string $warning) => str_contains($warning, 'secret manager')); + + expect($warning)->not->toBeNull() + ->and($warning)->toContain('my-app') + ->and($warning)->toContain('app-db') + ->and(json_encode($bundle))->not->toContain('vault-root-token'); + + $this->service->forceDelete(); + $this->application->forceDelete(); + $this->database->forceDelete(); + $this->server->forceDelete(); + Tag::query()->delete(); + ScheduledDatabaseBackup::query()->delete(); + ScheduledTask::query()->delete(); + $this->privateKey->delete(); + + $result = $this->importer->import($bundle, teamId: $this->team->id, dryRun: false, preserveUuids: true, adoptMode: true); + + expect($result['warnings'])->toContain($warning); +});