Spring Boot 4 Actuator in production: endpoints, security, custom health indicators
Companion repository for the ankurm.com article. Every transcript in docs/output/ was produced by running this project; scripts/run-all.sh regenerates all of them. Verified against Spring Boot 4.1.1 / Framework 7.0.9 / Security 7.1.1 / Micrometer 1.17.1 / kafka-clients 4.2.1 on Temurin JDK 25.0.4.1+1.
This commit is contained in:
122
docs/04-securing-actuator.md
Normal file
122
docs/04-securing-actuator.md
Normal file
@@ -0,0 +1,122 @@
|
||||
[← 03 Endpoint catalogue](03-endpoint-catalogue.md) · **04 · Securing Actuator** · [05 Custom health indicators →](05-custom-health-indicators.md)
|
||||
|
||||
# 04 — Securing Actuator
|
||||
|
||||
Two mechanisms, and you want both. Network isolation decides who can reach the port;
|
||||
authorisation decides who can use it. Neither is a substitute for the other.
|
||||
|
||||
## The separate management port
|
||||
|
||||
[`SecuredActuatorConfig`](../src/main/java/com/ankurm/actuator/config/SecuredActuatorConfig.java)
|
||||
handles authorisation. `application-mgmtport.yaml` handles reachability:
|
||||
|
||||
```yaml
|
||||
management:
|
||||
server:
|
||||
port: 9001
|
||||
address: 127.0.0.1
|
||||
endpoints:
|
||||
web:
|
||||
base-path: /manage
|
||||
```
|
||||
|
||||
From [`output/06-management-port.txt`](output/06-management-port.txt):
|
||||
|
||||
```
|
||||
GET :8080/actuator/health 401 <- application chain, no Actuator here
|
||||
GET :8080/orders/count 200
|
||||
GET :9001/manage/health 503
|
||||
GET :9001/actuator/health 404 <- base-path moved
|
||||
GET :9001/orders/count 404 <- separate context, no app controllers
|
||||
```
|
||||
|
||||
```
|
||||
LISTEN *:8080
|
||||
LISTEN [::ffff:127.0.0.1]:9001
|
||||
```
|
||||
|
||||
The last two lines are the argument. The management port runs a **separate application context**
|
||||
with its own `DispatcherServlet`, so it cannot serve your controllers, and binding it to
|
||||
loopback (or simply not listing it as a Kubernetes Service port) makes Actuator unreachable
|
||||
from the internet by routing rather than by a rule somebody has to keep correct.
|
||||
|
||||
This is defence in depth, not a replacement for authorisation. Anything running in the same pod
|
||||
or on the same host still reaches 9001.
|
||||
|
||||
## Match on `EndpointRequest`, not on a path
|
||||
|
||||
```java
|
||||
http.securityMatcher(EndpointRequest.toAnyEndpoint())
|
||||
.authorizeHttpRequests((requests) -> requests
|
||||
.requestMatchers(EndpointRequest.to(HealthEndpoint.class, InfoEndpoint.class)).permitAll()
|
||||
.anyRequest().hasRole("ACTUATOR"))
|
||||
```
|
||||
|
||||
A rule written as `requestMatchers("/actuator/**")` stops matching the moment somebody sets
|
||||
`management.endpoints.web.base-path` — as the `mgmtport` profile does. The endpoint moves, the
|
||||
rule does not follow, and the endpoint is now unprotected with no error anywhere. `EndpointRequest`
|
||||
asks the endpoint registry where things actually are.
|
||||
|
||||
## Order the chains
|
||||
|
||||
`@Order(1)` on the Actuator chain, `@Order(2)` on the application chain. Without explicit
|
||||
ordering, whichever chain Spring registers first wins for a given request, and an application
|
||||
chain ending in `permitAll()` will happily swallow `/actuator/**`.
|
||||
|
||||
## The resulting matrix
|
||||
|
||||
From [`output/05-secured-matrix.txt`](output/05-secured-matrix.txt), with exposure set to `"*"`:
|
||||
|
||||
| Request | Anonymous | `ops` / ROLE_ACTUATOR | Wrong password |
|
||||
|---|---|---|---|
|
||||
| `/actuator/health` | 503 | 503 | — |
|
||||
| `/actuator/info` | 200 | — | — |
|
||||
| `/actuator/env` | 401 | 200 | 401 |
|
||||
| `/actuator/beans` | 401 | 200 | — |
|
||||
| `/actuator/threaddump` | 401 | 200 | — |
|
||||
| `/actuator` (links) | 401 | — | — |
|
||||
| `/orders/count` | 200 | — | — |
|
||||
|
||||
Exposure is `"*"` and nothing leaks. That is the point: **exposure is not access control.**
|
||||
Use exposure to decide what exists, and the security chain to decide who may use it.
|
||||
|
||||
## `show-details: when-authorized`
|
||||
|
||||
The third option, and the one to reach for. Anonymous:
|
||||
|
||||
```json
|
||||
{"groups":["liveness","readiness"],"status":"DOWN"}
|
||||
```
|
||||
|
||||
Authenticated with `ROLE_ACTUATOR`: the full nine-component breakdown, naming `kafka` as the
|
||||
failing one. Same URL, same status code, different body. Your load balancer gets the signal it
|
||||
needs; an anonymous prober learns that something is wrong but not which of your dependencies to
|
||||
attack next.
|
||||
|
||||
```yaml
|
||||
management:
|
||||
endpoint:
|
||||
health:
|
||||
show-details: when-authorized
|
||||
roles: ACTUATOR
|
||||
```
|
||||
|
||||
## Make the Actuator chain stateless and CSRF-free
|
||||
|
||||
Spring Boot's auto-configured security is browser-shaped: it enables CSRF protection. Actuator
|
||||
is a machine-to-machine API, and `POST /actuator/loggers/{name}` from a script will be rejected
|
||||
before it reaches the endpoint.
|
||||
|
||||
While building this repository that showed up as a `401` on `POST /stub/upstream/mode` while
|
||||
`GET /orders/count` with the *same credentials* returned `200` — a genuinely confusing pair of
|
||||
results that has nothing to do with the credentials. See
|
||||
[`DemoSecurityConfig`](../src/main/java/com/ankurm/actuator/config/DemoSecurityConfig.java).
|
||||
|
||||
```java
|
||||
.csrf((csrf) -> csrf.disable())
|
||||
.sessionManagement((s) -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
[← 03](03-endpoint-catalogue.md) · **04** · [05 Custom health indicators →](05-custom-health-indicators.md)
|
||||
Reference in New Issue
Block a user