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 ad0f63fe..add9c38c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/config/FlywayConfig.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/FlywayConfig.java @@ -7,12 +7,15 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import javax.sql.DataSource; +import java.util.Map; @Configuration @RequiredArgsConstructor @Slf4j public class FlywayConfig { + private static final String GRAFANA_DB_PASSWORD_FALLBACK = "changeme-grafana-db-password"; + private final DataSource dataSource; @Bean(name = "flyway") @@ -21,6 +24,7 @@ public class FlywayConfig { Flyway flyway = Flyway.configure() .dataSource(dataSource) .locations("classpath:db/migration") + .placeholders(Map.of("grafanaDbPassword", resolveGrafanaDbPassword())) .baselineOnMigrate(true) .baselineVersion("4") .load(); @@ -28,4 +32,14 @@ public class FlywayConfig { log.info("Flyway: {} migration(s) applied.", result.migrationsExecuted); return flyway; } + + private String resolveGrafanaDbPassword() { + String value = System.getenv("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; + } + return value; + } } diff --git a/backend/src/main/resources/db/migration/V68__add_grafana_reader_role.sql b/backend/src/main/resources/db/migration/V68__add_grafana_reader_role.sql new file mode 100644 index 00000000..ffb185fa --- /dev/null +++ b/backend/src/main/resources/db/migration/V68__add_grafana_reader_role.sql @@ -0,0 +1,17 @@ +-- Read-only role used by the Grafana PostgreSQL datasource for the PO Overview +-- dashboard (issue #651). Password is injected at migration time via the Flyway +-- placeholder ${grafanaDbPassword}, supplied by FlywayConfig from the +-- GRAFANA_DB_PASSWORD environment variable. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'grafana_reader') THEN + EXECUTE format('CREATE ROLE grafana_reader WITH LOGIN PASSWORD %L', '${grafanaDbPassword}'); + ELSE + EXECUTE format('ALTER ROLE grafana_reader WITH LOGIN PASSWORD %L', '${grafanaDbPassword}'); + END IF; +END +$$; + +GRANT CONNECT ON DATABASE ${flyway:database} TO grafana_reader; +GRANT USAGE ON SCHEMA public TO grafana_reader; +GRANT SELECT ON audit_log, documents, transcription_blocks TO grafana_reader; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/config/GrafanaReaderRoleIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/config/GrafanaReaderRoleIntegrationTest.java new file mode 100644 index 00000000..f930b096 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/config/GrafanaReaderRoleIntegrationTest.java @@ -0,0 +1,47 @@ +package org.raddatz.familienarchiv.config; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({PostgresContainerConfig.class, FlywayConfig.class}) +class GrafanaReaderRoleIntegrationTest { + + @Autowired JdbcTemplate jdbc; + + @Test + void grafana_reader_has_select_on_audit_log() { + assertThat(hasSelect("audit_log")).isTrue(); + } + + @Test + void grafana_reader_has_select_on_documents() { + assertThat(hasSelect("documents")).isTrue(); + } + + @Test + void grafana_reader_has_select_on_transcription_blocks() { + assertThat(hasSelect("transcription_blocks")).isTrue(); + } + + @Test + void grafana_reader_has_no_select_on_app_users() { + assertThat(hasSelect("app_users")).isFalse(); + } + + private boolean hasSelect(String table) { + Boolean result = jdbc.queryForObject( + "SELECT has_table_privilege('grafana_reader', ?, 'SELECT')", + Boolean.class, + table); + return Boolean.TRUE.equals(result); + } +}