Skip to content

Commit 85a8f7e

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

26 files changed

+611
-183
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/health/HealthEndpointWebExtensionTests.java

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@
2121

2222
import org.junit.Test;
2323

24+
import org.springframework.boot.actuate.endpoint.SecurityContext;
2425
import org.springframework.boot.actuate.health.HealthEndpointWebExtension;
2526
import org.springframework.boot.actuate.health.HealthStatusHttpMapper;
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
/**
@@ -77,7 +79,8 @@ public void unauthenticatedUsersAreNotShownDetailsByDefault() {
7779
this.contextRunner.run((context) -> {
7880
HealthEndpointWebExtension extension = context
7981
.getBean(HealthEndpointWebExtension.class);
80-
assertThat(extension.getHealth(null).getBody().getDetails()).isEmpty();
82+
assertThat(extension.getHealth(mock(SecurityContext.class)).getBody()
83+
.getDetails()).isEmpty();
8184
});
8285
}
8386

@@ -86,7 +89,9 @@ public void authenticatedUsersAreShownDetailsByDefault() {
8689
this.contextRunner.run((context) -> {
8790
HealthEndpointWebExtension extension = context
8891
.getBean(HealthEndpointWebExtension.class);
89-
assertThat(extension.getHealth(mock(Principal.class)).getBody().getDetails())
92+
SecurityContext securityContext = mock(SecurityContext.class);
93+
given(securityContext.getPrincipal()).willReturn(mock(Principal.class));
94+
assertThat(extension.getHealth(securityContext).getBody().getDetails())
9095
.isNotEmpty();
9196
});
9297
}
@@ -110,9 +115,64 @@ public void detailsCanBeHiddenFromAuthenticatedUsers() {
110115
.run((context) -> {
111116
HealthEndpointWebExtension extension = context
112117
.getBean(HealthEndpointWebExtension.class);
113-
assertThat(extension.getHealth(mock(Principal.class)).getBody()
118+
assertThat(extension.getHealth(mock(SecurityContext.class)).getBody()
114119
.getDetails()).isEmpty();
115120
});
116121
}
117122

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

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

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
import org.junit.Test;
2323
import reactor.core.publisher.Mono;
2424

25+
import org.springframework.boot.actuate.endpoint.SecurityContext;
2526
import org.springframework.boot.actuate.health.Health;
2627
import org.springframework.boot.actuate.health.HealthEndpoint;
28+
import org.springframework.boot.actuate.health.HealthEndpointWebExtension;
2729
import org.springframework.boot.actuate.health.HealthIndicator;
2830
import org.springframework.boot.actuate.health.HealthStatusHttpMapper;
2931
import org.springframework.boot.actuate.health.ReactiveHealthEndpointWebExtension;
@@ -34,6 +36,7 @@
3436
import org.springframework.test.util.ReflectionTestUtils;
3537

3638
import static org.assertj.core.api.Assertions.assertThat;
39+
import static org.mockito.BDDMockito.given;
3740
import static org.mockito.Mockito.mock;
3841

3942
/**
@@ -86,7 +89,9 @@ public void regularAndReactiveHealthIndicatorsMatch() {
8689
ReactiveHealthEndpointWebExtension extension = context
8790
.getBean(ReactiveHealthEndpointWebExtension.class);
8891
Health endpointHealth = endpoint.health();
89-
Health extensionHealth = extension.health(mock(Principal.class))
92+
SecurityContext securityContext = mock(SecurityContext.class);
93+
given(securityContext.getPrincipal()).willReturn(mock(Principal.class));
94+
Health extensionHealth = extension.health(securityContext)
9095
.block().getBody();
9196
assertThat(endpointHealth.getDetails())
9297
.containsOnlyKeys("application", "first", "second");
@@ -100,7 +105,7 @@ public void unauthenticatedUsersAreNotShownDetailsByDefault() {
100105
this.contextRunner.run((context) -> {
101106
ReactiveHealthEndpointWebExtension extension = context
102107
.getBean(ReactiveHealthEndpointWebExtension.class);
103-
assertThat(extension.health(null).block().getBody().getDetails()).isEmpty();
108+
assertThat(extension.health(mock(SecurityContext.class)).block().getBody().getDetails()).isEmpty();
104109
});
105110
}
106111

@@ -109,7 +114,9 @@ public void authenticatedUsersAreShownDetailsByDefault() {
109114
this.contextRunner.run((context) -> {
110115
ReactiveHealthEndpointWebExtension extension = context
111116
.getBean(ReactiveHealthEndpointWebExtension.class);
112-
assertThat(extension.health(mock(Principal.class)).block().getBody()
117+
SecurityContext securityContext = mock(SecurityContext.class);
118+
given(securityContext.getPrincipal()).willReturn(mock(Principal.class));
119+
assertThat(extension.health(securityContext).block().getBody()
113120
.getDetails()).isNotEmpty();
114121
});
115122
}
@@ -133,11 +140,67 @@ public void detailsCanBeHiddenFromAuthenticatedUsers() {
133140
.run((context) -> {
134141
ReactiveHealthEndpointWebExtension extension = context
135142
.getBean(ReactiveHealthEndpointWebExtension.class);
136-
assertThat(extension.health(mock(Principal.class)).block().getBody()
143+
SecurityContext securityContext = mock(SecurityContext.class);
144+
assertThat(extension.health(securityContext).block().getBody()
137145
.getDetails()).isEmpty();
138146
});
139147
}
140148

149+
@Test
150+
public void detailsCanBeHiddenFromUnauthorizedUsers() {
151+
this.contextRunner
152+
.withPropertyValues(
153+
"management.endpoint.health.show-details=when-authorized",
154+
"management.endpoint.health.roles=ACTUATOR")
155+
.run((context) -> {
156+
ReactiveHealthEndpointWebExtension extension = context
157+
.getBean(ReactiveHealthEndpointWebExtension.class);
158+
SecurityContext securityContext = mock(SecurityContext.class);
159+
given(securityContext.getPrincipal())
160+
.willReturn(mock(Principal.class));
161+
given(securityContext.isUserInRole("ACTUATOR")).willReturn(false);
162+
assertThat(
163+
extension.health(securityContext).block().getBody().getDetails())
164+
.isEmpty();
165+
});
166+
}
167+
168+
@Test
169+
public void detailsCanBeShownToAuthorizedUsers() {
170+
this.contextRunner
171+
.withPropertyValues(
172+
"management.endpoint.health.show-details=when-authorized",
173+
"management.endpoint.health.roles=ACTUATOR")
174+
.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(
182+
extension.health(securityContext).block().getBody().getDetails())
183+
.isNotEmpty();
184+
});
185+
}
186+
187+
@Test
188+
public void roleCanBeCustomized() {
189+
this.contextRunner.withPropertyValues(
190+
"management.endpoint.health.show-details=when-authorized",
191+
"management.endpoint.health.roles=ADMIN").run((context) -> {
192+
ReactiveHealthEndpointWebExtension extension = context
193+
.getBean(ReactiveHealthEndpointWebExtension.class);
194+
SecurityContext securityContext = mock(SecurityContext.class);
195+
given(securityContext.getPrincipal())
196+
.willReturn(mock(Principal.class));
197+
given(securityContext.isUserInRole("ADMIN")).willReturn(true);
198+
assertThat(
199+
extension.health(securityContext).block().getBody().getDetails())
200+
.isNotEmpty();
201+
});
202+
}
203+
141204
@Configuration
142205
static class HealthIndicatorsConfiguration {
143206

spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/InvocationContext.java

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616

1717
package org.springframework.boot.actuate.endpoint;
1818

19-
import java.security.Principal;
2019
import java.util.Map;
2120

2221
import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker;
@@ -30,24 +29,26 @@
3029
*/
3130
public class InvocationContext {
3231

33-
private final Principal principal;
32+
private final SecurityContext securityContext;
3433

3534
private final Map<String, Object> arguments;
3635

3736
/**
3837
* Creates a new context for an operation being invoked by the given {@code principal}
3938
* with the given available {@code arguments}.
40-
* @param principal the principal invoking the operation. May be {@code null}
39+
* @param securityContext the current security context. Never {@code null}
4140
* @param arguments the arguments available to the operation. Never {@code null}
4241
*/
43-
public InvocationContext(Principal principal, Map<String, Object> arguments) {
42+
public InvocationContext(SecurityContext securityContext,
43+
Map<String, Object> arguments) {
44+
Assert.notNull(securityContext, "SecurityContext must not be null");
4445
Assert.notNull(arguments, "Arguments must not be null");
45-
this.principal = principal;
46+
this.securityContext = securityContext;
4647
this.arguments = arguments;
4748
}
4849

49-
public Principal getPrincipal() {
50-
return this.principal;
50+
public SecurityContext getSecurityContext() {
51+
return this.securityContext;
5152
}
5253

5354
public Map<String, Object> getArguments() {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright 2012-2018 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.actuate.endpoint;
18+
19+
import java.security.Principal;
20+
21+
/**
22+
* Security context in which an endpoint is being invoked.
23+
*
24+
* @author Andy Wilkinson
25+
* @since 2.0.0
26+
*/
27+
public interface SecurityContext {
28+
29+
/**
30+
* Return the currently authenticated {@link Principal} or {@code null}.
31+
* @return the principal or {@code null}
32+
*/
33+
Principal getPrincipal();
34+
35+
/**
36+
* Returns {@code true} if the currently authenticated user is in the given
37+
* {@code role}, or false otherwise.
38+
* @param role name of the role
39+
* @return {@code true} if the user is in the given role
40+
*/
41+
boolean isUserInRole(String role);
42+
43+
}

0 commit comments

Comments
 (0)