diff --git a/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java index 2cf85573..8b1a45ac 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java @@ -3,13 +3,16 @@ package org.raddatz.familienarchiv.security; import lombok.RequiredArgsConstructor; import org.raddatz.familienarchiv.user.CustomUserDetailsService; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.core.env.Environment; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; @@ -34,6 +37,28 @@ public class SecurityConfig { return authProvider; } + @Bean + @Order(1) + public SecurityFilterChain managementFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/actuator/**") + .authorizeHttpRequests(auth -> { + // Health and Prometheus are open — Docker health checks and Prometheus scraping need no credentials. + auth.requestMatchers("/actuator/health", "/actuator/prometheus").permitAll(); + // All other actuator endpoints (metrics, info, env, heapdump…) require authentication. + auth.anyRequest().authenticated(); + }) + // Explicitly return 401 for any unauthenticated actuator request. + // Without this override, Spring Security's DelegatingAuthenticationEntryPoint + // would redirect browser-like clients to the form-login page (302 → /login), + // making it impossible to distinguish "not authenticated" from "not found" in tests. + .exceptionHandling(ex -> ex.authenticationEntryPoint( + (req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED))) + .formLogin(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable); + return http.build(); + } + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http @@ -54,13 +79,9 @@ public class SecurityConfig { .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> { - // Both /actuator/health and /actuator/prometheus must be open. - // In Spring Boot 4.0 the management server (port 8081) shares the security filter chain; - // network isolation (port 8081 not published in docker-compose) is the security boundary. - // Health and Prometheus must be open — no credentials for Docker health checks or Prometheus scraping. - // Note: in Spring Boot 4.0 the management port shares the security filter chain, - // so these paths must be explicitly permitted here even though they are served on port 8081. - // Network isolation (port 8081 not published in docker-compose) is the outer security boundary. + // Actuator endpoints are governed by managementFilterChain (@Order(1)) above. + // The permitAll() lines here are a belt-and-suspenders fallback in case any + // actuator path escapes that chain's securityMatcher. See docs/adr/017. auth.requestMatchers("/actuator/health", "/actuator/prometheus").permitAll(); // Password reset endpoints are unauthenticated by nature auth.requestMatchers("/api/auth/forgot-password", "/api/auth/reset-password").permitAll(); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/ActuatorPrometheusIT.java b/backend/src/test/java/org/raddatz/familienarchiv/ActuatorPrometheusIT.java index 36d91bfa..e68ccfc7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/ActuatorPrometheusIT.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/ActuatorPrometheusIT.java @@ -27,14 +27,29 @@ class ActuatorPrometheusIT { S3Client s3Client; @Test - void prometheus_endpoint_returns_jvm_metrics_without_credentials() { + void prometheus_endpoint_returns_200_without_credentials() { ResponseEntity response = noThrowTemplate().getForEntity( "http://localhost:" + managementPort + "/actuator/prometheus", String.class); assertThat(response.getStatusCode().value()).isEqualTo(200); + } + + @Test + void prometheus_endpoint_returns_jvm_metrics() { + ResponseEntity response = noThrowTemplate().getForEntity( + "http://localhost:" + managementPort + "/actuator/prometheus", String.class); + assertThat(response.getBody()).contains("jvm_memory_used_bytes"); } + @Test + void actuator_metrics_requires_authentication() { + ResponseEntity response = noThrowTemplate().getForEntity( + "http://localhost:" + managementPort + "/actuator/metrics", String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(401); + } + private RestTemplate noThrowTemplate() { RestTemplate template = new RestTemplate(); template.setErrorHandler(new DefaultResponseErrorHandler() {