Files
familienarchiv/docs/adr/017-management-port-security.md
Marcel c2d092f435 docs(adr): add ADR-017 — Spring Boot 4.0 management port shares main security filter chain
Documents the architectural decision behind the dedicated management
SecurityFilterChain, the discovery that SB4+Jetty removed the isolated
management child-context security, and the consequences for actuator
endpoint exposure.

Addresses @markus blocker from PR #606 review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 15:46:45 +02:00

4.5 KiB

ADR-017: Spring Boot 4.0 management port shares the main security filter chain

Status

Accepted

Context

The Familienarchiv backend runs Spring Boot Actuator on a dedicated management port (8081) so that Caddy never proxies /actuator/* requests and Prometheus can reach the scrape endpoint directly inside archiv-net.

In earlier Spring Boot versions (< 4.0), the management server ran in an isolated child application context whose security was governed independently by ManagementWebSecurityAutoConfiguration. The main app's SecurityConfig filter chain (port 8080) never intercepted requests arriving on port 8081.

In Spring Boot 4.0 with Jetty, this isolation was removed. The management server now traverses the same Spring Security FilterChainProxy as the main application. Concretely:

  • Any SecurityFilterChain bean in the application context is evaluated for requests arriving on the management port.
  • There is no longer a separate "management security" child context.

This was discovered when Prometheus began receiving HTTP 401 responses from /actuator/prometheus despite the endpoint being exposed and the micrometer-registry-prometheus dependency being present. Prometheus rejected these responses with received unsupported Content-Type "text/html" because the main filter chain's form-login DelegatingAuthenticationEntryPoint was redirecting unauthenticated requests to /login (302 → HTML).

A secondary issue: Spring Boot 4.0 no longer auto-enables Prometheus metrics export — management.prometheus.metrics.export.enabled must be set explicitly, and the Prometheus scrape endpoint requires spring-boot-starter-micrometer-metrics (a new starter that was split out in Spring Boot 4.0).

Decision

  1. Dedicated management SecurityFilterChain scoped to /actuator/** at @Order(1) (highest precedence). This chain:

    • permitAll() for /actuator/health and /actuator/prometheus — required for Docker health checks and unauthenticated Prometheus scraping.
    • authenticated() for all other actuator endpoints — blocks /actuator/metrics, /actuator/info, etc. without credentials.
    • Uses an explicit 401 entry point (not form-login redirect) so that API clients — including Prometheus — receive a machine-readable status code rather than an HTML redirect.
    • No CSRF, no form login.
  2. Belt-and-suspenders permitAll() in the main SecurityFilterChain for /actuator/health and /actuator/prometheus, in case a future configuration change causes these paths to escape the management chain's securityMatcher.

  3. Network isolation as the outer defense boundary. Port 8081 is not published in docker-compose.yml and is not routed through Caddy. Only services inside archiv-net (primarily Prometheus and the Docker health checker) can reach the management port.

Alternatives rejected

  • Exclude ManagementWebSecurityAutoConfiguration: This auto-configuration no longer exists in Spring Boot 4.0. Exclusion is not applicable.
  • Keep SecurityConfig as the sole filter chain without @Order(1) management chain: The main chain's form-login DelegatingAuthenticationEntryPoint redirects browser-like clients to /login (302). Prometheus and automated health check clients cannot follow this redirect, so the endpoint would be unreachable without a dedicated chain that returns plain 401 or 200.
  • Per-endpoint @Order(1) filter chain using EndpointRequest.toAnyEndpoint(): The spring-boot-security artifact that provides EndpointRequest is not a transitive dependency of spring-boot-starter-actuator in Spring Boot 4.0. Using a path-based securityMatcher("/actuator/**") achieves the same scoping without an extra dependency.

Consequences

  • All actuator endpoints on port 8081 that are not explicitly permitAll()-ed require HTTP Basic credentials. Without valid credentials, the response is 401 (not a redirect).
  • Adding a new actuator endpoint to management.endpoints.web.exposure.include implicitly protects it via anyRequest().authenticated() in the management chain — no additional permitAll() needed unless intentional.
  • A regression test (ActuatorPrometheusIT) verifies:
    • /actuator/prometheus returns 200 without credentials.
    • /actuator/metrics returns 401 without credentials.
    • Prometheus metric names are present in the response body.
  • If port 8081 is ever accidentally published in docker-compose.yml, actuator endpoints other than health and prometheus are still protected by HTTP Basic. This reduces (but does not eliminate) the risk of inadvertent exposure.