Skip to content

Commit a2c05d5

Browse files
committed
Use role-based security to show details in the health endpoint
Closes spring-projectsgh-11869
1 parent 8ffa146 commit a2c05d5

27 files changed

+631
-203
lines changed

spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointProperties.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616

1717
package org.springframework.boot.actuate.autoconfigure.health;
1818

19+
import java.util.HashSet;
20+
import java.util.Set;
21+
1922
import org.springframework.boot.actuate.health.HealthEndpoint;
2023
import org.springframework.boot.actuate.health.ShowDetails;
2124
import org.springframework.boot.context.properties.ConfigurationProperties;
@@ -29,9 +32,15 @@
2932
public class HealthEndpointProperties {
3033

3134
/**
32-
* Whether to show full health details.
35+
* When to show full health details.
36+
*/
37+
private ShowDetails showDetails = ShowDetails.WHEN_AUTHORIZED;
38+
39+
/**
40+
* Roles used to determine whether or not a user is authorized to be shown details.
41+
* When empty, all authenticated users are authorized.
3342
*/
34-
private ShowDetails showDetails = ShowDetails.WHEN_AUTHENTICATED;
43+
private Set<String> roles = new HashSet<>();
3544

3645
public ShowDetails getShowDetails() {
3746
return this.showDetails;
@@ -41,4 +50,12 @@ public void setShowDetails(ShowDetails showDetails) {
4150
this.showDetails = showDetails;
4251
}
4352

53+
public Set<String> getRoles() {
54+
return this.roles;
55+
}
56+
57+
public void setRoles(Set<String> roles) {
58+
this.roles = roles;
59+
}
60+
4461
}

spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionConfiguration.java

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import org.springframework.boot.actuate.health.HealthEndpointWebExtension;
2828
import org.springframework.boot.actuate.health.HealthIndicator;
2929
import org.springframework.boot.actuate.health.HealthStatusHttpMapper;
30+
import org.springframework.boot.actuate.health.HealthWebEndpointResponseMapper;
3031
import org.springframework.boot.actuate.health.OrderedHealthAggregator;
3132
import org.springframework.boot.actuate.health.ReactiveHealthEndpointWebExtension;
3233
import org.springframework.boot.actuate.health.ReactiveHealthIndicator;
@@ -59,6 +60,15 @@ public HealthStatusHttpMapper createHealthStatusHttpMapper(
5960
return statusHttpMapper;
6061
}
6162

63+
@Bean
64+
@ConditionalOnMissingBean
65+
public HealthWebEndpointResponseMapper healthWebEndpointResponseMapper(
66+
HealthStatusHttpMapper statusHttpMapper,
67+
HealthEndpointProperties properties) {
68+
return new HealthWebEndpointResponseMapper(statusHttpMapper,
69+
properties.getShowDetails(), properties.getRoles());
70+
}
71+
6272
@Configuration
6373
@ConditionalOnWebApplication(type = Type.REACTIVE)
6474
static class ReactiveWebHealthConfiguration {
@@ -81,10 +91,9 @@ static class ReactiveWebHealthConfiguration {
8191
@ConditionalOnEnabledEndpoint
8292
@ConditionalOnBean(HealthEndpoint.class)
8393
public ReactiveHealthEndpointWebExtension reactiveHealthEndpointWebExtension(
84-
HealthStatusHttpMapper healthStatusHttpMapper,
85-
HealthEndpointProperties properties) {
94+
HealthWebEndpointResponseMapper responseMapper) {
8695
return new ReactiveHealthEndpointWebExtension(this.reactiveHealthIndicator,
87-
healthStatusHttpMapper, properties.getShowDetails());
96+
responseMapper);
8897
}
8998

9099
}
@@ -99,11 +108,10 @@ static class ServletWebHealthConfiguration {
99108
@ConditionalOnBean(HealthEndpoint.class)
100109
public HealthEndpointWebExtension healthEndpointWebExtension(
101110
ApplicationContext applicationContext,
102-
HealthStatusHttpMapper healthStatusHttpMapper,
103-
HealthEndpointProperties properties) {
111+
HealthWebEndpointResponseMapper responseMapper) {
104112
return new HealthEndpointWebExtension(
105113
HealthIndicatorBeansComposite.get(applicationContext),
106-
healthStatusHttpMapper, properties.getShowDetails());
114+
responseMapper);
107115
}
108116

109117
}

spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointDiscovererTests.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.junit.Test;
2525

2626
import org.springframework.boot.actuate.endpoint.InvocationContext;
27+
import org.springframework.boot.actuate.endpoint.SecurityContext;
2728
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
2829
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
2930
import org.springframework.boot.actuate.endpoint.invoke.convert.ConversionServiceParameterValueMapper;
@@ -58,8 +59,8 @@ public void getEndpointsShouldAddCloudFoundryHealthExtension() {
5859
for (ExposableWebEndpoint endpoint : endpoints) {
5960
if (endpoint.getId().equals("health")) {
6061
WebOperation operation = endpoint.getOperations().iterator().next();
61-
assertThat(operation
62-
.invoke(new InvocationContext(null, Collections.emptyMap())))
62+
assertThat(operation.invoke(new InvocationContext(
63+
mock(SecurityContext.class), Collections.emptyMap())))
6364
.isEqualTo("cf");
6465
}
6566
}

spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionTests.java

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,19 @@
1717
package org.springframework.boot.actuate.autoconfigure.health;
1818

1919
import java.security.Principal;
20-
import java.util.Map;
2120

2221
import org.junit.Test;
2322

23+
import org.springframework.boot.actuate.endpoint.SecurityContext;
24+
import org.springframework.boot.actuate.health.Health;
2425
import org.springframework.boot.actuate.health.HealthEndpointWebExtension;
25-
import org.springframework.boot.actuate.health.HealthStatusHttpMapper;
26+
import org.springframework.boot.actuate.health.HealthWebEndpointResponseMapper;
2627
import org.springframework.boot.autoconfigure.AutoConfigurations;
2728
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
2829
import org.springframework.test.util.ReflectionTestUtils;
2930

3031
import static org.assertj.core.api.Assertions.assertThat;
32+
import static org.mockito.BDDMockito.given;
3133
import static org.mockito.Mockito.mock;
3234

3335
/**
@@ -63,12 +65,17 @@ public void runWithCustomHealthMappingShouldMapStatusCode() {
6365
.withPropertyValues("management.health.status.http-mapping.CUSTOM=500")
6466
.run((context) -> {
6567
Object extension = context.getBean(HealthEndpointWebExtension.class);
66-
HealthStatusHttpMapper mapper = (HealthStatusHttpMapper) ReflectionTestUtils
67-
.getField(extension, "statusHttpMapper");
68-
Map<String, Integer> statusMappings = mapper.getStatusMapping();
69-
assertThat(statusMappings).containsEntry("DOWN", 503);
70-
assertThat(statusMappings).containsEntry("OUT_OF_SERVICE", 503);
71-
assertThat(statusMappings).containsEntry("CUSTOM", 500);
68+
HealthWebEndpointResponseMapper responseMapper = (HealthWebEndpointResponseMapper) ReflectionTestUtils
69+
.getField(extension, "responseMapper");
70+
Class<SecurityContext> securityContext = SecurityContext.class;
71+
assertThat(responseMapper
72+
.map(Health.down().build(), mock(securityContext))
73+
.getStatus()).isEqualTo(503);
74+
assertThat(responseMapper.map(Health.status("OUT_OF_SERVICE").build(),
75+
mock(securityContext)).getStatus()).isEqualTo(503);
76+
assertThat(responseMapper
77+
.map(Health.status("CUSTOM").build(), mock(securityContext))
78+
.getStatus()).isEqualTo(500);
7279
});
7380
}
7481

@@ -77,7 +84,8 @@ public void unauthenticatedUsersAreNotShownDetailsByDefault() {
7784
this.contextRunner.run((context) -> {
7885
HealthEndpointWebExtension extension = context
7986
.getBean(HealthEndpointWebExtension.class);
80-
assertThat(extension.getHealth(null).getBody().getDetails()).isEmpty();
87+
assertThat(extension.getHealth(mock(SecurityContext.class)).getBody()
88+
.getDetails()).isEmpty();
8189
});
8290
}
8391

@@ -86,7 +94,9 @@ public void authenticatedUsersAreShownDetailsByDefault() {
8694
this.contextRunner.run((context) -> {
8795
HealthEndpointWebExtension extension = context
8896
.getBean(HealthEndpointWebExtension.class);
89-
assertThat(extension.getHealth(mock(Principal.class)).getBody().getDetails())
97+
SecurityContext securityContext = mock(SecurityContext.class);
98+
given(securityContext.getPrincipal()).willReturn(mock(Principal.class));
99+
assertThat(extension.getHealth(securityContext).getBody().getDetails())
90100
.isNotEmpty();
91101
});
92102
}
@@ -110,9 +120,60 @@ public void detailsCanBeHiddenFromAuthenticatedUsers() {
110120
.run((context) -> {
111121
HealthEndpointWebExtension extension = context
112122
.getBean(HealthEndpointWebExtension.class);
113-
assertThat(extension.getHealth(mock(Principal.class)).getBody()
123+
assertThat(extension.getHealth(mock(SecurityContext.class)).getBody()
114124
.getDetails()).isEmpty();
115125
});
116126
}
117127

128+
@Test
129+
public void detailsCanBeHiddenFromUnauthorizedUsers() {
130+
this.contextRunner.withPropertyValues(
131+
"management.endpoint.health.show-details=when-authorized",
132+
"management.endpoint.health.roles=ACTUATOR").run((context) -> {
133+
HealthEndpointWebExtension extension = context
134+
.getBean(HealthEndpointWebExtension.class);
135+
SecurityContext securityContext = mock(SecurityContext.class);
136+
given(securityContext.getPrincipal())
137+
.willReturn(mock(Principal.class));
138+
given(securityContext.isUserInRole("ACTUATOR")).willReturn(false);
139+
assertThat(
140+
extension.getHealth(securityContext).getBody().getDetails())
141+
.isEmpty();
142+
});
143+
}
144+
145+
@Test
146+
public void detailsCanBeShownToAuthorizedUsers() {
147+
this.contextRunner.withPropertyValues(
148+
"management.endpoint.health.show-details=when-authorized",
149+
"management.endpoint.health.roles=ACTUATOR").run((context) -> {
150+
HealthEndpointWebExtension extension = context
151+
.getBean(HealthEndpointWebExtension.class);
152+
SecurityContext securityContext = mock(SecurityContext.class);
153+
given(securityContext.getPrincipal())
154+
.willReturn(mock(Principal.class));
155+
given(securityContext.isUserInRole("ACTUATOR")).willReturn(true);
156+
assertThat(
157+
extension.getHealth(securityContext).getBody().getDetails())
158+
.isNotEmpty();
159+
});
160+
}
161+
162+
@Test
163+
public void roleCanBeCustomized() {
164+
this.contextRunner.withPropertyValues(
165+
"management.endpoint.health.show-details=when-authorized",
166+
"management.endpoint.health.roles=ADMIN").run((context) -> {
167+
HealthEndpointWebExtension extension = context
168+
.getBean(HealthEndpointWebExtension.class);
169+
SecurityContext securityContext = mock(SecurityContext.class);
170+
given(securityContext.getPrincipal())
171+
.willReturn(mock(Principal.class));
172+
given(securityContext.isUserInRole("ADMIN")).willReturn(true);
173+
assertThat(
174+
extension.getHealth(securityContext).getBody().getDetails())
175+
.isNotEmpty();
176+
});
177+
}
178+
118179
}

spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/ReactiveHealthEndpointWebExtensionTests.java

Lines changed: 75 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,15 @@
1717
package org.springframework.boot.actuate.autoconfigure.health;
1818

1919
import java.security.Principal;
20-
import java.util.Map;
2120

2221
import org.junit.Test;
2322
import reactor.core.publisher.Mono;
2423

24+
import org.springframework.boot.actuate.endpoint.SecurityContext;
2525
import org.springframework.boot.actuate.health.Health;
2626
import org.springframework.boot.actuate.health.HealthEndpoint;
2727
import org.springframework.boot.actuate.health.HealthIndicator;
28-
import org.springframework.boot.actuate.health.HealthStatusHttpMapper;
28+
import org.springframework.boot.actuate.health.HealthWebEndpointResponseMapper;
2929
import org.springframework.boot.actuate.health.ReactiveHealthEndpointWebExtension;
3030
import org.springframework.boot.actuate.health.ReactiveHealthIndicator;
3131
import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
@@ -34,6 +34,7 @@
3434
import org.springframework.test.util.ReflectionTestUtils;
3535

3636
import static org.assertj.core.api.Assertions.assertThat;
37+
import static org.mockito.BDDMockito.given;
3738
import static org.mockito.Mockito.mock;
3839

3940
/**
@@ -69,12 +70,17 @@ public void runWithCustomHealthMappingShouldMapStatusCode() {
6970
.run((context) -> {
7071
Object extension = context
7172
.getBean(ReactiveHealthEndpointWebExtension.class);
72-
HealthStatusHttpMapper mapper = (HealthStatusHttpMapper) ReflectionTestUtils
73-
.getField(extension, "statusHttpMapper");
74-
Map<String, Integer> statusMappings = mapper.getStatusMapping();
75-
assertThat(statusMappings).containsEntry("DOWN", 503);
76-
assertThat(statusMappings).containsEntry("OUT_OF_SERVICE", 503);
77-
assertThat(statusMappings).containsEntry("CUSTOM", 500);
73+
HealthWebEndpointResponseMapper responseMapper = (HealthWebEndpointResponseMapper) ReflectionTestUtils
74+
.getField(extension, "responseMapper");
75+
Class<SecurityContext> securityContext = SecurityContext.class;
76+
assertThat(responseMapper
77+
.map(Health.down().build(), mock(securityContext))
78+
.getStatus()).isEqualTo(503);
79+
assertThat(responseMapper.map(Health.status("OUT_OF_SERVICE").build(),
80+
mock(securityContext)).getStatus()).isEqualTo(503);
81+
assertThat(responseMapper
82+
.map(Health.status("CUSTOM").build(), mock(securityContext))
83+
.getStatus()).isEqualTo(500);
7884
});
7985
}
8086

@@ -86,8 +92,11 @@ public void regularAndReactiveHealthIndicatorsMatch() {
8692
ReactiveHealthEndpointWebExtension extension = context
8793
.getBean(ReactiveHealthEndpointWebExtension.class);
8894
Health endpointHealth = endpoint.health();
89-
Health extensionHealth = extension.health(mock(Principal.class))
90-
.block().getBody();
95+
SecurityContext securityContext = mock(SecurityContext.class);
96+
given(securityContext.getPrincipal())
97+
.willReturn(mock(Principal.class));
98+
Health extensionHealth = extension.health(securityContext).block()
99+
.getBody();
91100
assertThat(endpointHealth.getDetails())
92101
.containsOnlyKeys("application", "first", "second");
93102
assertThat(extensionHealth.getDetails())
@@ -100,7 +109,8 @@ public void unauthenticatedUsersAreNotShownDetailsByDefault() {
100109
this.contextRunner.run((context) -> {
101110
ReactiveHealthEndpointWebExtension extension = context
102111
.getBean(ReactiveHealthEndpointWebExtension.class);
103-
assertThat(extension.health(null).block().getBody().getDetails()).isEmpty();
112+
assertThat(extension.health(mock(SecurityContext.class)).block().getBody()
113+
.getDetails()).isEmpty();
104114
});
105115
}
106116

@@ -109,8 +119,10 @@ public void authenticatedUsersAreShownDetailsByDefault() {
109119
this.contextRunner.run((context) -> {
110120
ReactiveHealthEndpointWebExtension extension = context
111121
.getBean(ReactiveHealthEndpointWebExtension.class);
112-
assertThat(extension.health(mock(Principal.class)).block().getBody()
113-
.getDetails()).isNotEmpty();
122+
SecurityContext securityContext = mock(SecurityContext.class);
123+
given(securityContext.getPrincipal()).willReturn(mock(Principal.class));
124+
assertThat(extension.health(securityContext).block().getBody().getDetails())
125+
.isNotEmpty();
114126
});
115127
}
116128

@@ -133,11 +145,60 @@ public void detailsCanBeHiddenFromAuthenticatedUsers() {
133145
.run((context) -> {
134146
ReactiveHealthEndpointWebExtension extension = context
135147
.getBean(ReactiveHealthEndpointWebExtension.class);
136-
assertThat(extension.health(mock(Principal.class)).block().getBody()
148+
SecurityContext securityContext = mock(SecurityContext.class);
149+
assertThat(extension.health(securityContext).block().getBody()
137150
.getDetails()).isEmpty();
138151
});
139152
}
140153

154+
@Test
155+
public void detailsCanBeHiddenFromUnauthorizedUsers() {
156+
this.contextRunner.withPropertyValues(
157+
"management.endpoint.health.show-details=when-authorized",
158+
"management.endpoint.health.roles=ACTUATOR").run((context) -> {
159+
ReactiveHealthEndpointWebExtension extension = context
160+
.getBean(ReactiveHealthEndpointWebExtension.class);
161+
SecurityContext securityContext = mock(SecurityContext.class);
162+
given(securityContext.getPrincipal())
163+
.willReturn(mock(Principal.class));
164+
given(securityContext.isUserInRole("ACTUATOR")).willReturn(false);
165+
assertThat(extension.health(securityContext).block().getBody()
166+
.getDetails()).isEmpty();
167+
});
168+
}
169+
170+
@Test
171+
public void detailsCanBeShownToAuthorizedUsers() {
172+
this.contextRunner.withPropertyValues(
173+
"management.endpoint.health.show-details=when-authorized",
174+
"management.endpoint.health.roles=ACTUATOR").run((context) -> {
175+
ReactiveHealthEndpointWebExtension extension = context
176+
.getBean(ReactiveHealthEndpointWebExtension.class);
177+
SecurityContext securityContext = mock(SecurityContext.class);
178+
given(securityContext.getPrincipal())
179+
.willReturn(mock(Principal.class));
180+
given(securityContext.isUserInRole("ACTUATOR")).willReturn(true);
181+
assertThat(extension.health(securityContext).block().getBody()
182+
.getDetails()).isNotEmpty();
183+
});
184+
}
185+
186+
@Test
187+
public void roleCanBeCustomized() {
188+
this.contextRunner.withPropertyValues(
189+
"management.endpoint.health.show-details=when-authorized",
190+
"management.endpoint.health.roles=ADMIN").run((context) -> {
191+
ReactiveHealthEndpointWebExtension extension = context
192+
.getBean(ReactiveHealthEndpointWebExtension.class);
193+
SecurityContext securityContext = mock(SecurityContext.class);
194+
given(securityContext.getPrincipal())
195+
.willReturn(mock(Principal.class));
196+
given(securityContext.isUserInRole("ADMIN")).willReturn(true);
197+
assertThat(extension.health(securityContext).block().getBody()
198+
.getDetails()).isNotEmpty();
199+
});
200+
}
201+
141202
@Configuration
142203
static class HealthIndicatorsConfiguration {
143204

0 commit comments

Comments
 (0)