From f0b801f171fb1e9821b4a2d1ad424e0f560229bd Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 21 May 2026 19:20:39 +0200 Subject: [PATCH] feat(observability): create grafana_reader read-only DB role Add Flyway V68 migration that provisions a read-only PostgreSQL role scoped to audit_log, documents, and transcription_blocks. The role's password is injected via the new ${grafanaDbPassword} Flyway placeholder, which FlywayConfig reads from the GRAFANA_DB_PASSWORD env var. The migration is idempotent: CREATE on first run, ALTER on re-run. Adds a Testcontainers integration test asserting positive grants on the three intended tables and a negative grant on app_users (NFR-SEC-01). Refs #651. Co-Authored-By: Claude Opus 4.7 --- .../familienarchiv/config/FlywayConfig.java | 14 ++++++ .../V68__add_grafana_reader_role.sql | 17 +++++++ .../GrafanaReaderRoleIntegrationTest.java | 47 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V68__add_grafana_reader_role.sql create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/config/GrafanaReaderRoleIntegrationTest.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 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); + } +}