From 3ea7f0b5b26215f38b2474dcd4bbc8584347db0c Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 22 May 2026 17:20:09 +0200 Subject: [PATCH] feat(observability): fail closed when GRAFANA_DB_PASSWORD is unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../familienarchiv/config/FlywayConfig.java | 22 +++++++---- .../config/FlywayConfigTest.java | 37 +++++++++++++++++++ .../src/test/resources/application.properties | 6 +++ 3 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/config/FlywayConfigTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/FlywayConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/config/FlywayConfig.java index add9c38c..5358fb56 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/config/FlywayConfig.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/FlywayConfig.java @@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j; import org.flywaydb.core.Flyway; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; import javax.sql.DataSource; import java.util.Map; @@ -14,9 +15,8 @@ import java.util.Map; @Slf4j public class FlywayConfig { - private static final String GRAFANA_DB_PASSWORD_FALLBACK = "changeme-grafana-db-password"; - private final DataSource dataSource; + private final Environment environment; @Bean(name = "flyway") public Flyway flyway() { @@ -33,12 +33,20 @@ public class FlywayConfig { return flyway; } - private String resolveGrafanaDbPassword() { - String value = System.getenv("GRAFANA_DB_PASSWORD"); + // Fail-closed: refuse to boot when GRAFANA_DB_PASSWORD is unset. The + // 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()) { - log.warn("GRAFANA_DB_PASSWORD is not set; the grafana_reader role will use a non-secret fallback. " - + "Set GRAFANA_DB_PASSWORD in production to enable the Grafana PostgreSQL datasource."); - return GRAFANA_DB_PASSWORD_FALLBACK; + throw new IllegalStateException( + "GRAFANA_DB_PASSWORD is required: it is consumed by " + + "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; } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/config/FlywayConfigTest.java b/backend/src/test/java/org/raddatz/familienarchiv/config/FlywayConfigTest.java new file mode 100644 index 00000000..7de3f5cf --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/config/FlywayConfigTest.java @@ -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"); + } +} diff --git a/backend/src/test/resources/application.properties b/backend/src/test/resources/application.properties index a6b847d2..c6b266c1 100644 --- a/backend/src/test/resources/application.properties +++ b/backend/src/test/resources/application.properties @@ -1,2 +1,8 @@ logging.level.root=WARN 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