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 <noreply@anthropic.com>
This commit is contained in:
@@ -7,12 +7,15 @@ import org.springframework.context.annotation.Bean;
|
|||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
import javax.sql.DataSource;
|
import javax.sql.DataSource;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@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;
|
||||||
|
|
||||||
@Bean(name = "flyway")
|
@Bean(name = "flyway")
|
||||||
@@ -21,6 +24,7 @@ public class FlywayConfig {
|
|||||||
Flyway flyway = Flyway.configure()
|
Flyway flyway = Flyway.configure()
|
||||||
.dataSource(dataSource)
|
.dataSource(dataSource)
|
||||||
.locations("classpath:db/migration")
|
.locations("classpath:db/migration")
|
||||||
|
.placeholders(Map.of("grafanaDbPassword", resolveGrafanaDbPassword()))
|
||||||
.baselineOnMigrate(true)
|
.baselineOnMigrate(true)
|
||||||
.baselineVersion("4")
|
.baselineVersion("4")
|
||||||
.load();
|
.load();
|
||||||
@@ -28,4 +32,14 @@ public class FlywayConfig {
|
|||||||
log.info("Flyway: {} migration(s) applied.", result.migrationsExecuted);
|
log.info("Flyway: {} migration(s) applied.", result.migrationsExecuted);
|
||||||
return flyway;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user