feat(observability): fail closed when GRAFANA_DB_PASSWORD is unset
FlywayConfig used to fall back to a hardcoded "changeme-grafana-db-password" string when the env var was missing. That published a known credential for the grafana_reader role (SELECT on audit_log, documents, transcription_blocks) into git history and made silent fail-open the default for any deploy that forgot the secret. Now resolution goes through Spring's Environment and throws IllegalStateException at startup when the value is unset or blank — same shape as UserDataInitializer's refusal to seed default admin creds. Tests inject via the global GRAFANA_DB_PASSWORD entry in test-resources application.properties so existing Flyway-loading test classes keep booting without per-class TestPropertySource boilerplate. FlywayConfigTest covers both branches against MockEnvironment without a Spring context. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.flywaydb.core.Flyway;
|
import org.flywaydb.core.Flyway;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
|
|
||||||
import javax.sql.DataSource;
|
import javax.sql.DataSource;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -14,9 +15,8 @@ import java.util.Map;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class FlywayConfig {
|
public class FlywayConfig {
|
||||||
|
|
||||||
private static final String GRAFANA_DB_PASSWORD_FALLBACK = "changeme-grafana-db-password";
|
|
||||||
|
|
||||||
private final DataSource dataSource;
|
private final DataSource dataSource;
|
||||||
|
private final Environment environment;
|
||||||
|
|
||||||
@Bean(name = "flyway")
|
@Bean(name = "flyway")
|
||||||
public Flyway flyway() {
|
public Flyway flyway() {
|
||||||
@@ -33,12 +33,20 @@ public class FlywayConfig {
|
|||||||
return flyway;
|
return flyway;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolveGrafanaDbPassword() {
|
// Fail-closed: refuse to boot when GRAFANA_DB_PASSWORD is unset. The
|
||||||
String value = System.getenv("GRAFANA_DB_PASSWORD");
|
// grafana_reader role's password is (re)set on every boot by
|
||||||
|
// R__grafana_reader_password.sql, so a missing env var means we'd either
|
||||||
|
// skip the rotation silently or — with a hardcoded fallback — publish a
|
||||||
|
// well-known credential for a role with SELECT on audit_log, documents,
|
||||||
|
// and transcription_blocks. Same shape as UserDataInitializer's refusal
|
||||||
|
// to seed default admin credentials outside dev/test/e2e.
|
||||||
|
String resolveGrafanaDbPassword() {
|
||||||
|
String value = environment.getProperty("GRAFANA_DB_PASSWORD");
|
||||||
if (value == null || value.isBlank()) {
|
if (value == null || value.isBlank()) {
|
||||||
log.warn("GRAFANA_DB_PASSWORD is not set; the grafana_reader role will use a non-secret fallback. "
|
throw new IllegalStateException(
|
||||||
+ "Set GRAFANA_DB_PASSWORD in production to enable the Grafana PostgreSQL datasource.");
|
"GRAFANA_DB_PASSWORD is required: it is consumed by "
|
||||||
return GRAFANA_DB_PASSWORD_FALLBACK;
|
+ "R__grafana_reader_password.sql to (re)set the grafana_reader "
|
||||||
|
+ "role's password on every boot. Generate with: openssl rand -hex 32");
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package org.raddatz.familienarchiv.config;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.mock.env.MockEnvironment;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class FlywayConfigTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveGrafanaDbPassword_throws_when_env_unset() {
|
||||||
|
FlywayConfig config = new FlywayConfig(null, new MockEnvironment());
|
||||||
|
|
||||||
|
assertThatThrownBy(config::resolveGrafanaDbPassword)
|
||||||
|
.isInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessageContaining("GRAFANA_DB_PASSWORD is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveGrafanaDbPassword_throws_when_env_blank() {
|
||||||
|
MockEnvironment env = new MockEnvironment().withProperty("GRAFANA_DB_PASSWORD", " ");
|
||||||
|
FlywayConfig config = new FlywayConfig(null, env);
|
||||||
|
|
||||||
|
assertThatThrownBy(config::resolveGrafanaDbPassword)
|
||||||
|
.isInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessageContaining("GRAFANA_DB_PASSWORD is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveGrafanaDbPassword_returns_value_when_env_set() {
|
||||||
|
MockEnvironment env = new MockEnvironment().withProperty("GRAFANA_DB_PASSWORD", "abc");
|
||||||
|
FlywayConfig config = new FlywayConfig(null, env);
|
||||||
|
|
||||||
|
assertThat(config.resolveGrafanaDbPassword()).isEqualTo("abc");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,8 @@
|
|||||||
logging.level.root=WARN
|
logging.level.root=WARN
|
||||||
logging.level.org.raddatz=INFO
|
logging.level.org.raddatz=INFO
|
||||||
|
|
||||||
|
# Default test value so FlywayConfig's fail-closed check passes without each
|
||||||
|
# test having to set GRAFANA_DB_PASSWORD explicitly. The actual value is
|
||||||
|
# irrelevant in tests — Flyway only uses it to set the grafana_reader role's
|
||||||
|
# password, which no test connects with.
|
||||||
|
GRAFANA_DB_PASSWORD=test-grafana-reader-password
|
||||||
|
|||||||
Reference in New Issue
Block a user