fix(secrets): keep the secret manager source when cloning resources

Cloning an application, database, service or environment did not copy
the secret manager link, so variables with remote secret references
failed on the next deployment with "no secret manager source is
configured".

Clones now get a copy of the link when the integration token belongs to
the clone's team. Server transfer does not export integration tokens or
links; the export now warns which resources need the token and link
again on the target instance.
This commit is contained in:
Andras Bacsai
2026-10-01 07:16:45 +02:00
parent 8d7469230c
commit d1cf3e6ef1
7 changed files with 223 additions and 0 deletions
+4
View File
@@ -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',
@@ -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',
@@ -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<int, Model> $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<string, mixed>
*/
+20
View File
@@ -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);
+2
View File
@@ -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) {
@@ -0,0 +1,136 @@
<?php
use App\Livewire\Project\CloneMe;
use App\Livewire\Project\Shared\ResourceOperations;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\Project;
use App\Models\SecretManagerLink;
use App\Models\Server;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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();
});
@@ -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);
});