diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index 8327cf2dfe..58d1607aa8 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -250,22 +250,16 @@ class StartDragonfly { $environment_variables = collect(); $this->resolvedRedisPassword = (string) $this->database->dragonfly_password; + $passwordVariable = $this->database->serverPasswordEnvironmentVariable(); foreach ($this->database->runtime_environment_variables as $env) { - $usesSecretManager = $this->database->environmentVariableUsesSecretManager($env); - if ($env->key === 'REDIS_PASSWORD' && ! $env->is_shared && ! $usesSecretManager) { - $env->update(['value' => $this->database->dragonfly_password]); - } $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); - // Credentials below are placed directly in the compose file (healthcheck, command). - $composeFileValue = $this->database->formatComposeFileValue($env, $rawValue); $environment_variables->push($env->key.'='.$resolvedValue); - if ($env->key === 'REDIS_PASSWORD') { - if ($env->is_shared && ! $usesSecretManager) { - $this->database->update(['dragonfly_password' => $rawValue]); - } - $this->resolvedRedisPassword = $composeFileValue; - $this->redisPasswordFromSecretManager = $usesSecretManager; + // A REDIS_PASSWORD variable overrides the stored password, which stays unchanged. + if ($passwordVariable?->is($env) && $rawValue !== '') { + // Credentials below are placed directly in the compose file (healthcheck, command). + $this->resolvedRedisPassword = $this->database->formatComposeFileValue($env, $rawValue); + $this->redisPasswordFromSecretManager = $this->database->environmentVariableUsesSecretManager($env); } } diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 6bd3c4b236..770d055001 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -250,22 +250,16 @@ class StartKeydb { $environment_variables = collect(); $this->resolvedRedisPassword = (string) $this->database->keydb_password; + $passwordVariable = $this->database->serverPasswordEnvironmentVariable(); foreach ($this->database->runtime_environment_variables as $env) { - $usesSecretManager = $this->database->environmentVariableUsesSecretManager($env); - if ($env->key === 'REDIS_PASSWORD' && ! $env->is_shared && ! $usesSecretManager) { - $env->update(['value' => $this->database->keydb_password]); - } $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); - // Credentials below are placed directly in the compose file (healthcheck, command). - $composeFileValue = $this->database->formatComposeFileValue($env, $rawValue); $environment_variables->push($env->key.'='.$resolvedValue); - if ($env->key === 'REDIS_PASSWORD') { - if ($env->is_shared && ! $usesSecretManager) { - $this->database->update(['keydb_password' => $rawValue]); - } - $this->resolvedRedisPassword = $composeFileValue; - $this->redisPasswordFromSecretManager = $usesSecretManager; + // A REDIS_PASSWORD variable overrides the stored password, which stays unchanged. + if ($passwordVariable?->is($env) && $rawValue !== '') { + // Credentials below are placed directly in the compose file (healthcheck, command). + $this->resolvedRedisPassword = $this->database->formatComposeFileValue($env, $rawValue); + $this->redisPasswordFromSecretManager = $this->database->environmentVariableUsesSecretManager($env); } } diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index e3d70534dc..15dea750f8 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -312,7 +312,7 @@ class StandaloneDragonfly extends BaseModel get: function () { $scheme = $this->enable_ssl ? 'rediss' : 'redis'; $port = $this->enable_ssl ? 6380 : 6379; - $encodedPass = rawurlencode($this->dragonfly_password); + $encodedPass = rawurlencode($this->connectionPassword()); $url = "{$scheme}://:{$encodedPass}@{$this->uuid}:{$port}/0"; if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') { @@ -334,7 +334,7 @@ class StandaloneDragonfly extends BaseModel return null; } $scheme = $this->enable_ssl ? 'rediss' : 'redis'; - $encodedPass = rawurlencode($this->dragonfly_password); + $encodedPass = rawurlencode($this->connectionPassword()); $url = "{$scheme}://:{$encodedPass}@{$serverIp}:{$this->public_port}/0"; if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') { @@ -369,6 +369,47 @@ class StandaloneDragonfly extends BaseModel return $this->morphMany(EnvironmentVariable::class, 'resourceable'); } + /** + * The REDIS_PASSWORD variable that sets the server password instead of the stored password. + * Databases created before this release (legacy_password_quoting) keep their v4.3.23 server + * password, the stored one, unless the variable reads a remote secret. + */ + public function serverPasswordEnvironmentVariable(): ?EnvironmentVariable + { + $environmentVariable = $this->runtime_environment_variables()->where('key', 'REDIS_PASSWORD')->first(); + + if (! $environmentVariable) { + return null; + } + + if ($this->legacy_password_quoting && ! $this->environmentVariableUsesSecretManager($environmentVariable)) { + return null; + } + + return $environmentVariable; + } + + /** + * The server password for connection URLs. A remote secret is not fetched here, so the URL + * shows its reference instead of a stale or wrong password. + */ + public function connectionPassword(): string + { + $environmentVariable = $this->serverPasswordEnvironmentVariable(); + + if ($environmentVariable) { + $password = $this->environmentVariableUsesSecretManager($environmentVariable) + ? (string) $environmentVariable->value + : (string) $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable); + + if ($password !== '') { + return $password; + } + } + + return (string) $this->dragonfly_password; + } + public function persistentStorages() { return $this->morphMany(LocalPersistentVolume::class, 'resource'); diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 5961ead8ce..35818a8742 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -313,7 +313,7 @@ class StandaloneKeydb extends BaseModel get: function () { $scheme = $this->enable_ssl ? 'rediss' : 'redis'; $port = $this->enable_ssl ? 6380 : 6379; - $encodedPass = rawurlencode($this->keydb_password); + $encodedPass = rawurlencode($this->connectionPassword()); $url = "{$scheme}://:{$encodedPass}@{$this->uuid}:{$port}/0"; if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') { @@ -335,7 +335,7 @@ class StandaloneKeydb extends BaseModel return null; } $scheme = $this->enable_ssl ? 'rediss' : 'redis'; - $encodedPass = rawurlencode($this->keydb_password); + $encodedPass = rawurlencode($this->connectionPassword()); $url = "{$scheme}://:{$encodedPass}@{$serverIp}:{$this->public_port}/0"; if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') { @@ -370,6 +370,47 @@ class StandaloneKeydb extends BaseModel return $this->morphMany(EnvironmentVariable::class, 'resourceable'); } + /** + * The REDIS_PASSWORD variable that sets the server password instead of the stored password. + * Databases created before this release (legacy_password_quoting) keep their v4.3.23 server + * password, the stored one, unless the variable reads a remote secret. + */ + public function serverPasswordEnvironmentVariable(): ?EnvironmentVariable + { + $environmentVariable = $this->runtime_environment_variables()->where('key', 'REDIS_PASSWORD')->first(); + + if (! $environmentVariable) { + return null; + } + + if ($this->legacy_password_quoting && ! $this->environmentVariableUsesSecretManager($environmentVariable)) { + return null; + } + + return $environmentVariable; + } + + /** + * The server password for connection URLs. A remote secret is not fetched here, so the URL + * shows its reference instead of a stale or wrong password. + */ + public function connectionPassword(): string + { + $environmentVariable = $this->serverPasswordEnvironmentVariable(); + + if ($environmentVariable) { + $password = $this->environmentVariableUsesSecretManager($environmentVariable) + ? (string) $environmentVariable->value + : (string) $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable); + + if ($password !== '') { + return $password; + } + } + + return (string) $this->keydb_password; + } + public function persistentStorages() { return $this->morphMany(LocalPersistentVolume::class, 'resource'); diff --git a/tests/Feature/KeydbDragonflyLegacyPasswordQuotingTest.php b/tests/Feature/KeydbDragonflyLegacyPasswordQuotingTest.php index 703a52f306..883ae97d84 100644 --- a/tests/Feature/KeydbDragonflyLegacyPasswordQuotingTest.php +++ b/tests/Feature/KeydbDragonflyLegacyPasswordQuotingTest.php @@ -197,30 +197,58 @@ it('quotes shell control characters in the password of a database created after 'pipe' => 'x|y', ]); -it('runs with the stored password and syncs a plain REDIS_PASSWORD variable to it', function (string $create, string $start, string $passwordColumn, string $commandFormat) { +it('runs with a plain REDIS_PASSWORD variable instead of the stored password without changing either', function (string $create, string $start, string $passwordColumn, string $commandFormat) { $database = $create($this->environment->id, $this->destination, [$passwordColumn => 'storedpass']); $env = $database->runtime_environment_variables()->create(['key' => 'REDIS_PASSWORD', 'value' => 'envpass']); $start::run($database->fresh(), new Activity); + $service = legacyPasswordQuotingStartedService($this->executor, $database->uuid); + expect($service['command'])->toBe(sprintf($commandFormat, "'envpass'")) + ->and($service['healthcheck'][3])->toBe('envpass') + ->and($service['environment'])->toContain('REDIS_PASSWORD=envpass') + ->and($env->fresh()->value)->toBe('envpass') + ->and($database->fresh()->{$passwordColumn})->toBe('storedpass'); +})->with('legacy-password-quoting-engines'); + +it('runs with the stored password again after the REDIS_PASSWORD variable is removed', function (string $create, string $start, string $passwordColumn, string $commandFormat) { + $database = $create($this->environment->id, $this->destination, [$passwordColumn => 'storedpass']); + $env = $database->runtime_environment_variables()->create(['key' => 'REDIS_PASSWORD', 'value' => 'envpass']); + $start::run($database->fresh(), new Activity); + + $env->delete(); + $start::run($database->fresh(), new Activity); + $service = legacyPasswordQuotingStartedService($this->executor, $database->uuid); expect($service['command'])->toBe(sprintf($commandFormat, "'storedpass'")) ->and($service['healthcheck'][3])->toBe('storedpass') - ->and($service['environment'])->toContain('REDIS_PASSWORD=storedpass') - ->and($env->fresh()->value)->toBe('storedpass') - ->and($database->fresh()->{$passwordColumn})->toBe('storedpass'); + ->and($service['environment'])->toContain('REDIS_PASSWORD=storedpass'); +})->with('legacy-password-quoting-engines'); + +it('runs with the stored password when the REDIS_PASSWORD variable is empty', function (string $create, string $start, string $passwordColumn, string $commandFormat) { + $database = $create($this->environment->id, $this->destination, [$passwordColumn => 'storedpass']); + $database->runtime_environment_variables()->create(['key' => 'REDIS_PASSWORD', 'value' => '']); + + $start::run($database->fresh(), new Activity); + + $service = legacyPasswordQuotingStartedService($this->executor, $database->uuid); + expect($service['command'])->toBe(sprintf($commandFormat, "'storedpass'")) + ->and($service['healthcheck'][3])->toBe('storedpass'); })->with('legacy-password-quoting-engines'); it('keeps the exact v4.3.23 command of a legacy database with a different plain REDIS_PASSWORD variable', function (string $create, string $start, string $passwordColumn, string $commandFormat) { $database = $create($this->environment->id, $this->destination, [$passwordColumn => 'stored;pass']); DB::table($database->getTable())->where('id', $database->id)->update(['legacy_password_quoting' => true]); - $database->runtime_environment_variables()->create(['key' => 'REDIS_PASSWORD', 'value' => 'envpass']); + $env = $database->runtime_environment_variables()->create(['key' => 'REDIS_PASSWORD', 'value' => 'envpass']); $start::run($database->fresh(), new Activity); $service = legacyPasswordQuotingStartedService($this->executor, $database->uuid); expect($service['command'])->toBe(sprintf($commandFormat, 'stored;pass')) ->and($service['healthcheck'][3])->toBe('stored;pass') + ->and($service['environment'])->toContain('REDIS_PASSWORD=envpass') + ->and($env->fresh()->value)->toBe('envpass') + ->and($database->fresh()->{$passwordColumn})->toBe('stored;pass') ->and($database->fresh()->legacy_password_quoting)->toBeTrue(); })->with('legacy-password-quoting-engines'); @@ -246,7 +274,7 @@ it('runs with a remote secret REDIS_PASSWORD without storing it', function (stri ->and($database->fresh()->{$passwordColumn})->toBe('storedpass'); })->with('legacy-password-quoting-engines'); -it('copies a shared REDIS_PASSWORD variable into the stored password', function (string $create, string $start, string $passwordColumn, string $commandFormat) { +it('runs with a shared REDIS_PASSWORD variable without storing it', function (string $create, string $start, string $passwordColumn, string $commandFormat) { SharedEnvironmentVariable::query()->create([ 'key' => 'REDIS_PASSWORD', 'value' => 'sharedpass', @@ -262,5 +290,67 @@ it('copies a shared REDIS_PASSWORD variable into the stored password', function expect($service['command'])->toBe(sprintf($commandFormat, "'sharedpass'")) ->and($service['healthcheck'][3])->toBe('sharedpass') ->and($env->fresh()->value)->toBe('{{team.REDIS_PASSWORD}}') - ->and($database->fresh()->{$passwordColumn})->toBe('sharedpass'); + ->and($database->fresh()->{$passwordColumn})->toBe('storedpass'); +})->with('legacy-password-quoting-engines'); + +it('keeps the stored password of a legacy database with a shared REDIS_PASSWORD variable', function (string $create, string $start, string $passwordColumn, string $commandFormat) { + SharedEnvironmentVariable::query()->create([ + 'key' => 'REDIS_PASSWORD', + 'value' => 'sharedpass', + 'type' => 'team', + 'team_id' => $this->environment->project->team_id, + ]); + $database = $create($this->environment->id, $this->destination, [$passwordColumn => 'stored;pass']); + DB::table($database->getTable())->where('id', $database->id)->update(['legacy_password_quoting' => true]); + $database->runtime_environment_variables()->create(['key' => 'REDIS_PASSWORD', 'value' => '{{team.REDIS_PASSWORD}}']); + + $start::run($database->fresh(), new Activity); + + $service = legacyPasswordQuotingStartedService($this->executor, $database->uuid); + expect($service['command'])->toBe(sprintf($commandFormat, 'stored;pass')) + ->and($service['healthcheck'][3])->toBe('stored;pass') + ->and($database->fresh()->{$passwordColumn})->toBe('stored;pass') + ->and($database->fresh()->legacy_password_quoting)->toBeTrue(); +})->with('legacy-password-quoting-engines'); + +it('shows the password the server uses in the connection URLs', function (string $create, string $start, string $passwordColumn, string $commandFormat) { + SharedEnvironmentVariable::query()->create([ + 'key' => 'REDIS_PASSWORD', + 'value' => 'shared/pass', + 'type' => 'team', + 'team_id' => $this->environment->project->team_id, + ]); + $database = $create($this->environment->id, $this->destination, [$passwordColumn => 'stored/pass', 'is_public' => true, 'public_port' => 16379]); + expect($database->fresh()->internal_db_url)->toContain(':stored%2Fpass@') + ->and($database->fresh()->external_db_url)->toContain(':stored%2Fpass@'); + + $env = $database->runtime_environment_variables()->create(['key' => 'REDIS_PASSWORD', 'value' => 'env/pass']); + expect($database->fresh()->internal_db_url)->toContain(':env%2Fpass@') + ->and($database->fresh()->external_db_url)->toContain(':env%2Fpass@'); + + $env->update(['value' => '{{team.REDIS_PASSWORD}}']); + expect($database->fresh()->internal_db_url)->toContain(':shared%2Fpass@') + ->and($database->fresh()->external_db_url)->toContain(':shared%2Fpass@'); + + DB::table($database->getTable())->where('id', $database->id)->update(['legacy_password_quoting' => true]); + expect($database->fresh()->internal_db_url)->toContain(':stored%2Fpass@') + ->and($database->fresh()->external_db_url)->toContain(':stored%2Fpass@'); +})->with('legacy-password-quoting-engines'); + +it('shows the unresolved remote secret reference in the connection URL without fetching it', function (string $create, string $start, string $passwordColumn, string $commandFormat) { + Http::fake(); + $token = IntegrationToken::query()->create([ + 'team_id' => $this->environment->project->team_id, + 'provider' => 'doppler', + 'name' => 'Doppler', + 'token' => 'the-secret-token', + 'capabilities' => ['secrets'], + ]); + $database = $create($this->environment->id, $this->destination, [$passwordColumn => 'storedpass']); + $database->secretManagerLink()->create(['integration_token_id' => $token->id]); + $database->runtime_environment_variables()->create(['key' => 'REDIS_PASSWORD', 'value' => '{{vault.REDIS_PASSWORD}}']); + + expect($database->fresh()->internal_db_url)->toContain(':'.rawurlencode('{{vault.REDIS_PASSWORD}}').'@') + ->not->toContain('storedpass'); + Http::assertNothingSent(); })->with('legacy-password-quoting-engines');