fix(databases): let REDIS_PASSWORD override the KeyDB and Dragonfly password

The stored password is the default. Before, a local REDIS_PASSWORD was
rewritten to the stored password, and a shared one replaced the stored
password, so the old password was lost.

- A local, shared, or remote secret REDIS_PASSWORD sets the server
  password; the stored password and the variable are not changed
- Removing the variable, or an empty value, uses the stored password
- Connection URLs show the password in use; for a remote secret they
  show the reference, not the secret
- Databases created before this release keep the v4.3.23 behavior
This commit is contained in:
Andras Bacsai
2026-10-05 21:25:57 +02:00
parent 8aa57d66e8
commit 152e06cf43
5 changed files with 195 additions and 35 deletions
+6 -12
View File
@@ -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);
}
}
+6 -12
View File
@@ -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);
}
}
+43 -2
View File
@@ -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');
+43 -2
View File
@@ -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');
@@ -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');