Merge branch '1.5.x'
This commit is contained in:
commit
24f5125a8b
|
@ -54,7 +54,6 @@ import org.springframework.context.annotation.ConditionContext;
|
|||
import org.springframework.context.annotation.Conditional;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.type.AnnotatedTypeMetadata;
|
||||
import org.springframework.util.ClassUtils;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
|
@ -162,7 +161,7 @@ public class EndpointWebMvcManagementContextConfiguration {
|
|||
@ConditionalOnEnabledEndpoint("health")
|
||||
public HealthMvcEndpoint healthMvcEndpoint(HealthEndpoint delegate) {
|
||||
HealthMvcEndpoint healthMvcEndpoint = new HealthMvcEndpoint(delegate,
|
||||
isHealthSecure());
|
||||
this.managementServerProperties.getSecurity().isEnabled());
|
||||
if (this.healthMvcEndpointProperties.getMapping() != null) {
|
||||
healthMvcEndpoint
|
||||
.addStatusMapping(this.healthMvcEndpointProperties.getMapping());
|
||||
|
@ -206,17 +205,6 @@ public class EndpointWebMvcManagementContextConfiguration {
|
|||
return new AuditEventsMvcEndpoint(auditEventRepository);
|
||||
}
|
||||
|
||||
private boolean isHealthSecure() {
|
||||
return isSpringSecurityAvailable()
|
||||
&& this.managementServerProperties.getSecurity().isEnabled();
|
||||
}
|
||||
|
||||
private boolean isSpringSecurityAvailable() {
|
||||
return ClassUtils.isPresent(
|
||||
"org.springframework.security.config.annotation.web.WebSecurityConfigurer",
|
||||
getClass().getClassLoader());
|
||||
}
|
||||
|
||||
private static class LogFileCondition extends SpringBootCondition {
|
||||
|
||||
@Override
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
package org.springframework.boot.actuate.cloudfoundry;
|
||||
|
||||
import java.security.Principal;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.boot.actuate.endpoint.HealthEndpoint;
|
||||
import org.springframework.boot.actuate.endpoint.mvc.HealthMvcEndpoint;
|
||||
|
@ -36,7 +36,7 @@ class CloudFoundryHealthMvcEndpoint extends HealthMvcEndpoint {
|
|||
}
|
||||
|
||||
@Override
|
||||
protected boolean exposeHealthDetails(Principal principal) {
|
||||
protected boolean exposeHealthDetails(HttpServletRequest request) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
@ -16,12 +16,11 @@
|
|||
|
||||
package org.springframework.boot.actuate.endpoint.mvc;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.boot.actuate.endpoint.HealthEndpoint;
|
||||
import org.springframework.boot.actuate.health.Health;
|
||||
import org.springframework.boot.actuate.health.Status;
|
||||
|
@ -33,10 +32,7 @@ import org.springframework.core.env.Environment;
|
|||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.ClassUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
|
@ -49,6 +45,7 @@ import org.springframework.web.bind.annotation.ResponseBody;
|
|||
* @author Andy Wilkinson
|
||||
* @author Phillip Webb
|
||||
* @author Eddú Meléndez
|
||||
* @author Madhura Bhave
|
||||
* @since 1.1.0
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "endpoints.health")
|
||||
|
@ -59,11 +56,7 @@ public class HealthMvcEndpoint extends AbstractEndpointMvcAdapter<HealthEndpoint
|
|||
|
||||
private Map<String, HttpStatus> statusMapping = new HashMap<String, HttpStatus>();
|
||||
|
||||
private RelaxedPropertyResolver healthPropertyResolver;
|
||||
|
||||
private RelaxedPropertyResolver endpointPropertyResolver;
|
||||
|
||||
private RelaxedPropertyResolver roleResolver;
|
||||
private RelaxedPropertyResolver securityPropertyResolver;
|
||||
|
||||
private long lastAccess = 0;
|
||||
|
||||
|
@ -86,11 +79,7 @@ public class HealthMvcEndpoint extends AbstractEndpointMvcAdapter<HealthEndpoint
|
|||
|
||||
@Override
|
||||
public void setEnvironment(Environment environment) {
|
||||
this.healthPropertyResolver = new RelaxedPropertyResolver(environment,
|
||||
"endpoints.health.");
|
||||
this.endpointPropertyResolver = new RelaxedPropertyResolver(environment,
|
||||
"endpoints.");
|
||||
this.roleResolver = new RelaxedPropertyResolver(environment,
|
||||
this.securityPropertyResolver = new RelaxedPropertyResolver(environment,
|
||||
"management.security.");
|
||||
}
|
||||
|
||||
|
@ -136,12 +125,12 @@ public class HealthMvcEndpoint extends AbstractEndpointMvcAdapter<HealthEndpoint
|
|||
|
||||
@RequestMapping(produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
@ResponseBody
|
||||
public Object invoke(Principal principal) {
|
||||
public Object invoke(HttpServletRequest request) {
|
||||
if (!getDelegate().isEnabled()) {
|
||||
// Shouldn't happen because the request mapping should not be registered
|
||||
return getDisabledResponse();
|
||||
}
|
||||
Health health = getHealth(principal);
|
||||
Health health = getHealth(request);
|
||||
HttpStatus status = getStatus(health);
|
||||
if (status != null) {
|
||||
return new ResponseEntity<Health>(health, status);
|
||||
|
@ -163,13 +152,13 @@ public class HealthMvcEndpoint extends AbstractEndpointMvcAdapter<HealthEndpoint
|
|||
return null;
|
||||
}
|
||||
|
||||
private Health getHealth(Principal principal) {
|
||||
private Health getHealth(HttpServletRequest request) {
|
||||
long accessTime = System.currentTimeMillis();
|
||||
if (isCacheStale(accessTime)) {
|
||||
this.lastAccess = accessTime;
|
||||
this.cached = getDelegate().invoke();
|
||||
}
|
||||
if (exposeHealthDetails(principal)) {
|
||||
if (exposeHealthDetails(request)) {
|
||||
return this.cached;
|
||||
}
|
||||
return Health.status(this.cached.getStatus()).build();
|
||||
|
@ -182,44 +171,19 @@ public class HealthMvcEndpoint extends AbstractEndpointMvcAdapter<HealthEndpoint
|
|||
return (accessTime - this.lastAccess) >= getDelegate().getTimeToLive();
|
||||
}
|
||||
|
||||
protected boolean exposeHealthDetails(Principal principal) {
|
||||
return isSecure(principal) || isUnrestricted();
|
||||
}
|
||||
|
||||
private boolean isSecure(Principal principal) {
|
||||
if (principal == null || principal.getClass().getName().contains("Anonymous")) {
|
||||
return false;
|
||||
protected boolean exposeHealthDetails(HttpServletRequest request) {
|
||||
if (!this.secure) {
|
||||
return true;
|
||||
}
|
||||
if (isSpringSecurityAuthentication(principal)) {
|
||||
Authentication authentication = (Authentication) principal;
|
||||
List<String> roles = Arrays.asList(StringUtils
|
||||
.trimArrayElements(StringUtils.commaDelimitedListToStringArray(
|
||||
this.roleResolver.getProperty("roles", "ROLE_ACTUATOR"))));
|
||||
for (GrantedAuthority authority : authentication.getAuthorities()) {
|
||||
String name = authority.getAuthority();
|
||||
for (String role : roles) {
|
||||
if (role.equals(name) || ("ROLE_" + role).equals(name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
String[] roles = StringUtils.commaDelimitedListToStringArray(
|
||||
this.securityPropertyResolver.getProperty("roles", "ROLE_ACTUATOR"));
|
||||
roles = StringUtils.trimArrayElements(roles);
|
||||
for (String role : roles) {
|
||||
if (request.isUserInRole(role) || request.isUserInRole("ROLE_" + role)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isSpringSecurityAuthentication(Principal principal) {
|
||||
return ClassUtils.isPresent("org.springframework.security.core.Authentication",
|
||||
null) && (principal instanceof Authentication);
|
||||
}
|
||||
|
||||
private boolean isUnrestricted() {
|
||||
Boolean sensitive = this.healthPropertyResolver.getProperty("sensitive",
|
||||
Boolean.class);
|
||||
if (sensitive == null) {
|
||||
sensitive = this.endpointPropertyResolver.getProperty("sensitive",
|
||||
Boolean.class);
|
||||
}
|
||||
return !this.secure && !Boolean.TRUE.equals(sensitive);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -85,6 +85,18 @@
|
|||
"type": "java.util.Map<java.lang.String,java.lang.Object>",
|
||||
"description": "Arbitrary properties to add to the info endpoint."
|
||||
},
|
||||
{
|
||||
"name": "management.cloudfoundry.enabled",
|
||||
"type": "java.lang.Boolean",
|
||||
"description": "Enable extended Cloud Foundry actuator endpoints.",
|
||||
"defaultValue": true
|
||||
},
|
||||
{
|
||||
"name": "management.cloudfoundry.skip-ssl-validation",
|
||||
"type": "java.lang.Boolean",
|
||||
"description": "Skip SSL verification for Cloud Foundry actuator endpoint security calls.",
|
||||
"defaultValue": false
|
||||
},
|
||||
{
|
||||
"name": "management.health.cassandra.enabled",
|
||||
"type": "java.lang.Boolean",
|
||||
|
|
|
@ -32,6 +32,7 @@ import org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration;
|
|||
import org.springframework.boot.test.util.EnvironmentTestUtils;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockServletContext;
|
||||
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
|
||||
|
||||
|
@ -60,8 +61,9 @@ public class HealthMvcEndpointAutoConfigurationTests {
|
|||
this.context.setServletContext(new MockServletContext());
|
||||
this.context.register(TestConfiguration.class);
|
||||
this.context.refresh();
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
Health health = (Health) this.context.getBean(HealthMvcEndpoint.class)
|
||||
.invoke(null);
|
||||
.invoke(request);
|
||||
assertThat(health.getStatus()).isEqualTo(Status.UP);
|
||||
assertThat(health.getDetails().get("foo")).isNull();
|
||||
}
|
||||
|
|
|
@ -18,20 +18,21 @@ package org.springframework.boot.actuate.endpoint.mvc;
|
|||
|
||||
import java.util.Collections;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import org.springframework.boot.actuate.endpoint.HealthEndpoint;
|
||||
import org.springframework.boot.actuate.health.Health;
|
||||
import org.springframework.boot.actuate.health.Status;
|
||||
import org.springframework.boot.test.util.EnvironmentTestUtils;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
import org.springframework.core.env.PropertySource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockServletContext;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
|
@ -44,36 +45,36 @@ import static org.mockito.Mockito.mock;
|
|||
* @author Dave Syer
|
||||
* @author Andy Wilkinson
|
||||
* @author Eddú Meléndez
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
public class HealthMvcEndpointTests {
|
||||
|
||||
private static final PropertySource<?> NON_SENSITIVE = new MapPropertySource("test",
|
||||
Collections.<String, Object>singletonMap("endpoints.health.sensitive",
|
||||
"false"));
|
||||
|
||||
private static final PropertySource<?> SECURITY_ROLES = new MapPropertySource("test",
|
||||
Collections.<String, Object>singletonMap("management.security.roles",
|
||||
"HERO, USER"));
|
||||
|
||||
private HttpServletRequest request = new MockHttpServletRequest();
|
||||
|
||||
private HealthEndpoint endpoint = null;
|
||||
|
||||
private HealthMvcEndpoint mvc = null;
|
||||
|
||||
private MockEnvironment environment;
|
||||
|
||||
private UsernamePasswordAuthenticationToken user = createAuthenticationToken(
|
||||
private HttpServletRequest user = createAuthenticationToken(
|
||||
"ROLE_USER");
|
||||
|
||||
private UsernamePasswordAuthenticationToken actuator = createAuthenticationToken(
|
||||
private HttpServletRequest actuator = createAuthenticationToken(
|
||||
"ROLE_ACTUATOR");
|
||||
|
||||
private UsernamePasswordAuthenticationToken hero = createAuthenticationToken(
|
||||
private HttpServletRequest hero = createAuthenticationToken(
|
||||
"ROLE_HERO");
|
||||
|
||||
private UsernamePasswordAuthenticationToken createAuthenticationToken(
|
||||
String authority) {
|
||||
return new UsernamePasswordAuthenticationToken("user", "password",
|
||||
AuthorityUtils.commaSeparatedStringToAuthorityList(authority));
|
||||
private HttpServletRequest createAuthenticationToken(
|
||||
String role) {
|
||||
MockServletContext servletContext = new MockServletContext();
|
||||
servletContext.declareRoles(role);
|
||||
return new MockHttpServletRequest(servletContext);
|
||||
}
|
||||
|
||||
@Before
|
||||
|
@ -88,7 +89,7 @@ public class HealthMvcEndpointTests {
|
|||
@Test
|
||||
public void up() {
|
||||
given(this.endpoint.invoke()).willReturn(new Health.Builder().up().build());
|
||||
Object result = this.mvc.invoke(null);
|
||||
Object result = this.mvc.invoke(this.request);
|
||||
assertThat(result instanceof Health).isTrue();
|
||||
assertThat(((Health) result).getStatus() == Status.UP).isTrue();
|
||||
}
|
||||
|
@ -97,7 +98,7 @@ public class HealthMvcEndpointTests {
|
|||
@Test
|
||||
public void down() {
|
||||
given(this.endpoint.invoke()).willReturn(new Health.Builder().down().build());
|
||||
Object result = this.mvc.invoke(null);
|
||||
Object result = this.mvc.invoke(this.request);
|
||||
assertThat(result instanceof ResponseEntity).isTrue();
|
||||
ResponseEntity<Health> response = (ResponseEntity<Health>) result;
|
||||
assertThat(response.getBody().getStatus() == Status.DOWN).isTrue();
|
||||
|
@ -111,7 +112,7 @@ public class HealthMvcEndpointTests {
|
|||
.willReturn(new Health.Builder().status("OK").build());
|
||||
this.mvc.setStatusMapping(
|
||||
Collections.singletonMap("OK", HttpStatus.INTERNAL_SERVER_ERROR));
|
||||
Object result = this.mvc.invoke(null);
|
||||
Object result = this.mvc.invoke(this.request);
|
||||
assertThat(result instanceof ResponseEntity).isTrue();
|
||||
ResponseEntity<Health> response = (ResponseEntity<Health>) result;
|
||||
assertThat(response.getBody().getStatus().equals(new Status("OK"))).isTrue();
|
||||
|
@ -125,7 +126,7 @@ public class HealthMvcEndpointTests {
|
|||
.willReturn(new Health.Builder().outOfService().build());
|
||||
this.mvc.setStatusMapping(Collections.singletonMap("out-of-service",
|
||||
HttpStatus.INTERNAL_SERVER_ERROR));
|
||||
Object result = this.mvc.invoke(null);
|
||||
Object result = this.mvc.invoke(this.request);
|
||||
assertThat(result instanceof ResponseEntity).isTrue();
|
||||
ResponseEntity<Health> response = (ResponseEntity<Health>) result;
|
||||
assertThat(response.getBody().getStatus().equals(Status.OUT_OF_SERVICE)).isTrue();
|
||||
|
@ -133,10 +134,9 @@ public class HealthMvcEndpointTests {
|
|||
}
|
||||
|
||||
@Test
|
||||
public void secureEvenWhenNotSensitive() {
|
||||
public void presenceOfRightRoleShouldExposeDetails() {
|
||||
given(this.endpoint.invoke())
|
||||
.willReturn(new Health.Builder().up().withDetail("foo", "bar").build());
|
||||
given(this.endpoint.isSensitive()).willReturn(false);
|
||||
Object result = this.mvc.invoke(this.actuator);
|
||||
assertThat(result instanceof Health).isTrue();
|
||||
assertThat(((Health) result).getStatus() == Status.UP).isTrue();
|
||||
|
@ -144,7 +144,18 @@ public class HealthMvcEndpointTests {
|
|||
}
|
||||
|
||||
@Test
|
||||
public void secureNonAdmin() {
|
||||
public void managementSecurityDisabledShouldExposeDetails() throws Exception {
|
||||
this.mvc = new HealthMvcEndpoint(this.endpoint, false);
|
||||
given(this.endpoint.invoke())
|
||||
.willReturn(new Health.Builder().up().withDetail("foo", "bar").build());
|
||||
Object result = this.mvc.invoke(this.user);
|
||||
assertThat(result instanceof Health).isTrue();
|
||||
assertThat(((Health) result).getStatus() == Status.UP).isTrue();
|
||||
assertThat(((Health) result).getDetails().get("foo")).isEqualTo("bar");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rightRoleNotPresentShouldNotExposeDetails() {
|
||||
given(this.endpoint.invoke())
|
||||
.willReturn(new Health.Builder().up().withDetail("foo", "bar").build());
|
||||
Object result = this.mvc.invoke(this.user);
|
||||
|
@ -154,7 +165,7 @@ public class HealthMvcEndpointTests {
|
|||
}
|
||||
|
||||
@Test
|
||||
public void secureCustomRole() {
|
||||
public void customRolePresentShouldExposeDetails() {
|
||||
this.environment.getPropertySources().addLast(SECURITY_ROLES);
|
||||
given(this.endpoint.invoke())
|
||||
.willReturn(new Health.Builder().up().withDetail("foo", "bar").build());
|
||||
|
@ -165,7 +176,7 @@ public class HealthMvcEndpointTests {
|
|||
}
|
||||
|
||||
@Test
|
||||
public void secureCustomRoleNoAccess() {
|
||||
public void customRoleShouldNotExposeDetailsForDefaultRole() {
|
||||
this.environment.getPropertySources().addLast(SECURITY_ROLES);
|
||||
given(this.endpoint.invoke())
|
||||
.willReturn(new Health.Builder().up().withDetail("foo", "bar").build());
|
||||
|
@ -178,7 +189,6 @@ public class HealthMvcEndpointTests {
|
|||
@Test
|
||||
public void healthIsCached() {
|
||||
given(this.endpoint.getTimeToLive()).willReturn(10000L);
|
||||
given(this.endpoint.isSensitive()).willReturn(true);
|
||||
given(this.endpoint.invoke())
|
||||
.willReturn(new Health.Builder().up().withDetail("foo", "bar").build());
|
||||
Object result = this.mvc.invoke(this.actuator);
|
||||
|
@ -188,7 +198,7 @@ public class HealthMvcEndpointTests {
|
|||
assertThat(health.getDetails()).hasSize(1);
|
||||
assertThat(health.getDetails().get("foo")).isEqualTo("bar");
|
||||
given(this.endpoint.invoke()).willReturn(new Health.Builder().down().build());
|
||||
result = this.mvc.invoke(null); // insecure now
|
||||
result = this.mvc.invoke(this.request); // insecure now
|
||||
assertThat(result instanceof Health).isTrue();
|
||||
health = (Health) result;
|
||||
// so the result is cached
|
||||
|
@ -197,52 +207,16 @@ public class HealthMvcEndpointTests {
|
|||
assertThat(health.getDetails()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void insecureAnonymousAccessUnrestricted() {
|
||||
this.mvc = new HealthMvcEndpoint(this.endpoint, false);
|
||||
this.mvc.setEnvironment(this.environment);
|
||||
given(this.endpoint.invoke())
|
||||
.willReturn(new Health.Builder().up().withDetail("foo", "bar").build());
|
||||
Object result = this.mvc.invoke(null);
|
||||
assertThat(result instanceof Health).isTrue();
|
||||
assertThat(((Health) result).getStatus() == Status.UP).isTrue();
|
||||
assertThat(((Health) result).getDetails().get("foo")).isEqualTo("bar");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void insensitiveAnonymousAccessRestricted() {
|
||||
this.environment.getPropertySources().addLast(NON_SENSITIVE);
|
||||
given(this.endpoint.invoke())
|
||||
.willReturn(new Health.Builder().up().withDetail("foo", "bar").build());
|
||||
Object result = this.mvc.invoke(null);
|
||||
assertThat(result instanceof Health).isTrue();
|
||||
assertThat(((Health) result).getStatus() == Status.UP).isTrue();
|
||||
assertThat(((Health) result).getDetails().get("foo")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void insecureInsensitiveAnonymousAccessUnrestricted() {
|
||||
this.mvc = new HealthMvcEndpoint(this.endpoint, false);
|
||||
this.mvc.setEnvironment(this.environment);
|
||||
this.environment.getPropertySources().addLast(NON_SENSITIVE);
|
||||
given(this.endpoint.invoke())
|
||||
.willReturn(new Health.Builder().up().withDetail("foo", "bar").build());
|
||||
Object result = this.mvc.invoke(null);
|
||||
assertThat(result instanceof Health).isTrue();
|
||||
assertThat(((Health) result).getStatus() == Status.UP).isTrue();
|
||||
assertThat(((Health) result).getDetails().get("foo")).isEqualTo("bar");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void noCachingWhenTimeToLiveIsZero() {
|
||||
given(this.endpoint.getTimeToLive()).willReturn(0L);
|
||||
given(this.endpoint.invoke())
|
||||
.willReturn(new Health.Builder().up().withDetail("foo", "bar").build());
|
||||
Object result = this.mvc.invoke(null);
|
||||
Object result = this.mvc.invoke(this.request);
|
||||
assertThat(result instanceof Health).isTrue();
|
||||
assertThat(((Health) result).getStatus() == Status.UP).isTrue();
|
||||
given(this.endpoint.invoke()).willReturn(new Health.Builder().down().build());
|
||||
result = this.mvc.invoke(null);
|
||||
result = this.mvc.invoke(this.request);
|
||||
@SuppressWarnings("unchecked")
|
||||
Health health = ((ResponseEntity<Health>) result).getBody();
|
||||
assertThat(health.getStatus() == Status.DOWN).isTrue();
|
||||
|
@ -251,59 +225,16 @@ public class HealthMvcEndpointTests {
|
|||
@Test
|
||||
public void newValueIsReturnedOnceTtlExpires() throws InterruptedException {
|
||||
given(this.endpoint.getTimeToLive()).willReturn(50L);
|
||||
given(this.endpoint.isSensitive()).willReturn(false);
|
||||
given(this.endpoint.invoke())
|
||||
.willReturn(new Health.Builder().up().withDetail("foo", "bar").build());
|
||||
Object result = this.mvc.invoke(null);
|
||||
Object result = this.mvc.invoke(this.request);
|
||||
assertThat(result instanceof Health).isTrue();
|
||||
assertThat(((Health) result).getStatus() == Status.UP).isTrue();
|
||||
Thread.sleep(100);
|
||||
given(this.endpoint.invoke()).willReturn(new Health.Builder().down().build());
|
||||
result = this.mvc.invoke(null);
|
||||
result = this.mvc.invoke(this.request);
|
||||
@SuppressWarnings("unchecked")
|
||||
Health health = ((ResponseEntity<Health>) result).getBody();
|
||||
assertThat(health.getStatus() == Status.DOWN).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void detailIsHiddenWhenAllEndpointsAreSensitive() {
|
||||
EnvironmentTestUtils.addEnvironment(this.environment, "endpoints.sensitive:true");
|
||||
this.mvc = new HealthMvcEndpoint(this.endpoint, false);
|
||||
this.mvc.setEnvironment(this.environment);
|
||||
given(this.endpoint.invoke())
|
||||
.willReturn(new Health.Builder().up().withDetail("foo", "bar").build());
|
||||
Object result = this.mvc.invoke(null);
|
||||
assertThat(result instanceof Health).isTrue();
|
||||
assertThat(((Health) result).getStatus() == Status.UP).isTrue();
|
||||
assertThat(((Health) result).getDetails().get("foo")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void detailIsHiddenWhenHealthEndpointIsSensitive() {
|
||||
EnvironmentTestUtils.addEnvironment(this.environment,
|
||||
"endpoints.health.sensitive:true");
|
||||
this.mvc = new HealthMvcEndpoint(this.endpoint, false);
|
||||
this.mvc.setEnvironment(this.environment);
|
||||
given(this.endpoint.invoke())
|
||||
.willReturn(new Health.Builder().up().withDetail("foo", "bar").build());
|
||||
Object result = this.mvc.invoke(null);
|
||||
assertThat(result instanceof Health).isTrue();
|
||||
assertThat(((Health) result).getStatus() == Status.UP).isTrue();
|
||||
assertThat(((Health) result).getDetails().get("foo")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void detailIsHiddenWhenOnlyHealthEndpointIsSensitive() {
|
||||
EnvironmentTestUtils.addEnvironment(this.environment,
|
||||
"endpoints.health.sensitive:true", "endpoints.sensitive:false");
|
||||
this.mvc = new HealthMvcEndpoint(this.endpoint, false);
|
||||
this.mvc.setEnvironment(this.environment);
|
||||
given(this.endpoint.invoke())
|
||||
.willReturn(new Health.Builder().up().withDetail("foo", "bar").build());
|
||||
Object result = this.mvc.invoke(null);
|
||||
assertThat(result instanceof Health).isTrue();
|
||||
assertThat(((Health) result).getStatus() == Status.UP).isTrue();
|
||||
assertThat(((Health) result).getDetails().get("foo")).isNull();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ import org.springframework.boot.autoconfigure.web.HttpMessageConvertersAutoConfi
|
|||
import org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration;
|
||||
import org.springframework.boot.junit.runner.classpath.ClassPathExclusions;
|
||||
import org.springframework.boot.junit.runner.classpath.ModifiedClassPathRunner;
|
||||
import org.springframework.boot.test.util.EnvironmentTestUtils;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.mock.web.MockServletContext;
|
||||
|
@ -48,6 +49,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||
* Integration tests for the health endpoint when Spring Security is not available.
|
||||
*
|
||||
* @author Andy Wilkinson
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
@RunWith(ModifiedClassPathRunner.class)
|
||||
@ClassPathExclusions("spring-security-*.jar")
|
||||
|
@ -61,14 +63,28 @@ public class NoSpringSecurityHealthMvcEndpointIntegrationTests {
|
|||
}
|
||||
|
||||
@Test
|
||||
public void healthDetailIsPresent() throws Exception {
|
||||
public void healthDetailNotPresent() throws Exception {
|
||||
this.context = new AnnotationConfigWebApplicationContext();
|
||||
this.context.setServletContext(new MockServletContext());
|
||||
this.context.register(TestConfiguration.class);
|
||||
this.context.refresh();
|
||||
MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(this.context).build();
|
||||
mockMvc.perform(get("/health")).andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("\"hello\":\"world\"")));
|
||||
.andExpect(content().string(containsString("\"status\":\"UP\"")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void healthDetailPresent() throws Exception {
|
||||
this.context = new AnnotationConfigWebApplicationContext();
|
||||
this.context.setServletContext(new MockServletContext());
|
||||
this.context.register(TestConfiguration.class);
|
||||
EnvironmentTestUtils.addEnvironment(this.context,
|
||||
"management.security.enabled:false");
|
||||
this.context.refresh();
|
||||
MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(this.context).build();
|
||||
mockMvc.perform(get("/health")).andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString(
|
||||
"\"status\":\"UP\",\"test\":{\"status\":\"UP\",\"hello\":\"world\"}")));
|
||||
}
|
||||
|
||||
@ImportAutoConfiguration({ JacksonAutoConfiguration.class,
|
||||
|
|
|
@ -1056,6 +1056,8 @@ content into your application; rather pick only the properties that you need.
|
|||
management.add-application-context-header=true # Add the "X-Application-Context" HTTP header in each response.
|
||||
management.address= # Network address that the management endpoints should bind to.
|
||||
management.context-path= # Management endpoint context-path. For instance `/actuator`
|
||||
management.cloudfoundry.enabled= # Enable extended Cloud Foundry actuator endpoints
|
||||
management.cloudfoundry.skip-ssl-validation= # Skip SSL verification for Cloud Foundry actuator endpoint security calls
|
||||
management.port= # Management endpoint HTTP port. Uses the same port as the application by default. Configure a different port to use management-specific SSL.
|
||||
management.security.enabled=true # Enable security.
|
||||
management.security.roles=ACTUATOR # Comma-separated list of roles that can access the management endpoint.
|
||||
|
|
|
@ -545,7 +545,7 @@ buildscript {
|
|||
}
|
||||
|
||||
springBoot {
|
||||
layoutFactory = new com.example.CustomLayoutFactory()
|
||||
layoutFactory = new com.example.CustomLayoutFactory()
|
||||
}
|
||||
----
|
||||
|
||||
|
|
|
@ -177,12 +177,12 @@ element):
|
|||
|
||||
[source,xml,indent=0]
|
||||
----
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
</resource>
|
||||
</resources>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
</resource>
|
||||
</resources>
|
||||
----
|
||||
|
||||
and (inside `<plugins/>`):
|
||||
|
|
|
@ -536,11 +536,32 @@ all enabled endpoints to be exposed over HTTP. The default convention is to use
|
|||
|
||||
|
||||
[[production-ready-sensitive-endpoints]]
|
||||
=== Securing sensitive endpoints
|
||||
If you add '`Spring Security`' to your project, all sensitive endpoints exposed over HTTP
|
||||
will be protected. By default '`basic`' authentication will be used with the username
|
||||
`user` and a generated password (which is printed on the console when the application
|
||||
starts).
|
||||
=== Accessing sensitive endpoints
|
||||
By default all sensitive HTTP endpoints are secured such that only users that have an
|
||||
`ACTUATOR` role may access them. Security is enforced using the standard
|
||||
`HttpServletRequest.isUserInRole` method.
|
||||
|
||||
TIP: Use the `management.security.roles` property if you want something different to
|
||||
`ACTUATOR`.
|
||||
|
||||
If you are deploying applications behind a firewall, you may prefer that all your actuator
|
||||
endpoints can be accessed without requiring authentication. You can do this by changing
|
||||
the `management.security.enabled` property:
|
||||
|
||||
.application.properties
|
||||
[source,properties,indent=0]
|
||||
----
|
||||
management.security.enabled=false
|
||||
----
|
||||
|
||||
NOTE: By default, actuator endpoints are exposed on the same port that serves regular
|
||||
HTTP traffic. Take care not to accidentally expose sensitive information if you change
|
||||
the `management.security.enabled` property.
|
||||
|
||||
If you're deploying applications publicly, you may want to add '`Spring Security`' to
|
||||
handle user authentication. When '`Spring Security`' is added, by default '`basic`'
|
||||
authentication will be used with the username `user` and a generated password (which is
|
||||
printed on the console when the application starts).
|
||||
|
||||
TIP: Generated passwords are logged as the application starts. Search for '`Using default
|
||||
security password`'.
|
||||
|
@ -556,10 +577,6 @@ in your `application.properties`:
|
|||
management.security.roles=SUPERUSER
|
||||
----
|
||||
|
||||
TIP: If you don't use Spring Security and your HTTP endpoints are exposed publicly,
|
||||
you should carefully consider which endpoints you enable. See
|
||||
<<production-ready-customizing-endpoints>> for details of how you can set
|
||||
`endpoints.enabled` to `false` then "`opt-in`" only specific endpoints.
|
||||
|
||||
|
||||
[[production-ready-customizing-management-server-context-path]]
|
||||
|
@ -1093,19 +1110,19 @@ Example:
|
|||
|
||||
[source,java,indent=0]
|
||||
----
|
||||
@Bean
|
||||
@ExportMetricWriter
|
||||
MetricWriter metricWriter(MetricExportProperties export) {
|
||||
return new RedisMetricRepository(connectionFactory,
|
||||
export.getRedis().getPrefix(), export.getRedis().getKey());
|
||||
}
|
||||
@Bean
|
||||
@ExportMetricWriter
|
||||
MetricWriter metricWriter(MetricExportProperties export) {
|
||||
return new RedisMetricRepository(connectionFactory,
|
||||
export.getRedis().getPrefix(), export.getRedis().getKey());
|
||||
}
|
||||
----
|
||||
|
||||
.application.properties
|
||||
[source,properties]
|
||||
[source,properties,indent=0]
|
||||
----
|
||||
spring.metrics.export.redis.prefix: metrics.mysystem.${spring.application.name:application}.${random.value:0000}
|
||||
spring.metrics.export.redis.key: keys.metrics.mysystem
|
||||
spring.metrics.export.redis.prefix: metrics.mysystem.${spring.application.name:application}.${random.value:0000}
|
||||
spring.metrics.export.redis.key: keys.metrics.mysystem
|
||||
----
|
||||
|
||||
The prefix is constructed with the application name and id at the end, so it can easily be used
|
||||
|
@ -1144,21 +1161,21 @@ Example:
|
|||
|
||||
[source,indent=0]
|
||||
----
|
||||
curl localhost:4242/api/query?start=1h-ago&m=max:counter.status.200.root
|
||||
[
|
||||
{
|
||||
"metric": "counter.status.200.root",
|
||||
"tags": {
|
||||
"domain": "org.springframework.metrics",
|
||||
"process": "b968a76"
|
||||
},
|
||||
"aggregateTags": [],
|
||||
"dps": {
|
||||
"1430492872": 2,
|
||||
"1430492875": 6
|
||||
curl localhost:4242/api/query?start=1h-ago&m=max:counter.status.200.root
|
||||
[
|
||||
{
|
||||
"metric": "counter.status.200.root",
|
||||
"tags": {
|
||||
"domain": "org.springframework.metrics",
|
||||
"process": "b968a76"
|
||||
},
|
||||
"aggregateTags": [],
|
||||
"dps": {
|
||||
"1430492872": 2,
|
||||
"1430492875": 6
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
----
|
||||
|
||||
|
||||
|
@ -1177,14 +1194,14 @@ Alternatively, you can provide a `@Bean` of type `StatsdMetricWriter` and mark i
|
|||
|
||||
[source,java,indent=0]
|
||||
----
|
||||
@Value("${spring.application.name:application}.${random.value:0000}")
|
||||
private String prefix = "metrics";
|
||||
@Value("${spring.application.name:application}.${random.value:0000}")
|
||||
private String prefix = "metrics";
|
||||
|
||||
@Bean
|
||||
@ExportMetricWriter
|
||||
MetricWriter metricWriter() {
|
||||
return new StatsdMetricWriter(prefix, "localhost", 8125);
|
||||
}
|
||||
@Bean
|
||||
@ExportMetricWriter
|
||||
MetricWriter metricWriter() {
|
||||
return new StatsdMetricWriter(prefix, "localhost", 8125);
|
||||
}
|
||||
----
|
||||
|
||||
|
||||
|
@ -1200,11 +1217,11 @@ Example:
|
|||
|
||||
[source,java,indent=0]
|
||||
----
|
||||
@Bean
|
||||
@ExportMetricWriter
|
||||
MetricWriter metricWriter(MBeanExporter exporter) {
|
||||
return new JmxMetricWriter(exporter);
|
||||
}
|
||||
@Bean
|
||||
@ExportMetricWriter
|
||||
MetricWriter metricWriter(MBeanExporter exporter) {
|
||||
return new JmxMetricWriter(exporter);
|
||||
}
|
||||
----
|
||||
|
||||
Each metric is exported as an individual MBean. The format for the `ObjectNames` is given
|
||||
|
@ -1231,24 +1248,24 @@ Example:
|
|||
|
||||
[source,java,indent=0]
|
||||
----
|
||||
@Autowired
|
||||
private MetricExportProperties export;
|
||||
@Autowired
|
||||
private MetricExportProperties export;
|
||||
|
||||
@Bean
|
||||
public PublicMetrics metricsAggregate() {
|
||||
return new MetricReaderPublicMetrics(aggregatesMetricReader());
|
||||
}
|
||||
@Bean
|
||||
public PublicMetrics metricsAggregate() {
|
||||
return new MetricReaderPublicMetrics(aggregatesMetricReader());
|
||||
}
|
||||
|
||||
private MetricReader globalMetricsForAggregation() {
|
||||
return new RedisMetricRepository(this.connectionFactory,
|
||||
this.export.getRedis().getAggregatePrefix(), this.export.getRedis().getKey());
|
||||
}
|
||||
private MetricReader globalMetricsForAggregation() {
|
||||
return new RedisMetricRepository(this.connectionFactory,
|
||||
this.export.getRedis().getAggregatePrefix(), this.export.getRedis().getKey());
|
||||
}
|
||||
|
||||
private MetricReader aggregatesMetricReader() {
|
||||
AggregateMetricReader repository = new AggregateMetricReader(
|
||||
globalMetricsForAggregation());
|
||||
return repository;
|
||||
}
|
||||
private MetricReader aggregatesMetricReader() {
|
||||
AggregateMetricReader repository = new AggregateMetricReader(
|
||||
globalMetricsForAggregation());
|
||||
return repository;
|
||||
}
|
||||
----
|
||||
|
||||
NOTE: The example above uses `MetricExportProperties` to inject and extract the key and
|
||||
|
@ -1312,34 +1329,34 @@ and obtain basic information about the last 100 requests:
|
|||
|
||||
[source,json,indent=0]
|
||||
----
|
||||
[{
|
||||
"timestamp": 1394343677415,
|
||||
"info": {
|
||||
"method": "GET",
|
||||
"path": "/trace",
|
||||
"headers": {
|
||||
"request": {
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Connection": "keep-alive",
|
||||
"Accept-Encoding": "gzip, deflate",
|
||||
"User-Agent": "Mozilla/5.0 Gecko/Firefox",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Cookie": "_ga=GA1.1.827067509.1390890128; ..."
|
||||
"Authorization": "Basic ...",
|
||||
"Host": "localhost:8080"
|
||||
},
|
||||
"response": {
|
||||
"Strict-Transport-Security": "max-age=31536000 ; includeSubDomains",
|
||||
"X-Application-Context": "application:8080",
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
"status": "200"
|
||||
}
|
||||
}
|
||||
}
|
||||
},{
|
||||
"timestamp": 1394343684465,
|
||||
...
|
||||
}]
|
||||
[{
|
||||
"timestamp": 1394343677415,
|
||||
"info": {
|
||||
"method": "GET",
|
||||
"path": "/trace",
|
||||
"headers": {
|
||||
"request": {
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Connection": "keep-alive",
|
||||
"Accept-Encoding": "gzip, deflate",
|
||||
"User-Agent": "Mozilla/5.0 Gecko/Firefox",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Cookie": "_ga=GA1.1.827067509.1390890128; ..."
|
||||
"Authorization": "Basic ...",
|
||||
"Host": "localhost:8080"
|
||||
},
|
||||
"response": {
|
||||
"Strict-Transport-Security": "max-age=31536000 ; includeSubDomains",
|
||||
"X-Application-Context": "application:8080",
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
"status": "200"
|
||||
}
|
||||
}
|
||||
}
|
||||
},{
|
||||
"timestamp": 1394343684465,
|
||||
...
|
||||
}]
|
||||
----
|
||||
|
||||
|
||||
|
@ -1396,6 +1413,67 @@ customize the file name and path via the `Writer` constructor.
|
|||
|
||||
|
||||
|
||||
[[production-ready-cloudfoundry]]
|
||||
== Cloud Foundry support
|
||||
Spring Boot's actuator module includes additional support that is activated when you
|
||||
deploy to a compatible Cloud Foundry instance. The `/cloudfoundryapplication` path
|
||||
provides an alternative secured route to all `NamedMvcEndpoint` beans.
|
||||
|
||||
The extended support allows Cloud Foundry management UIs (such as the web
|
||||
application that you can use to view deployed applications) to be augmented with Spring
|
||||
Boot actuator information. For example, an application status page may include full health
|
||||
information instead of the typical "`running`" or "`stopped`" status.
|
||||
|
||||
NOTE: The `/cloudfoundryapplication` path is not directly accessible to regular users.
|
||||
In order to use the endpoint a valid UAA token must be passed with the request.
|
||||
|
||||
|
||||
|
||||
[[production-ready-cloudfoundry-disable]]
|
||||
=== Disabling extended Cloud Foundry actuator support
|
||||
If you want to fully disable the `/cloudfoundryapplication` endpoints you can add the
|
||||
following to your `application.properties` file:
|
||||
|
||||
|
||||
.application.properties
|
||||
[source,properties,indent=0]
|
||||
----
|
||||
management.cloudfoundry.enabled=false
|
||||
----
|
||||
|
||||
|
||||
|
||||
[[production-ready-cloudfoundry-ssl]]
|
||||
=== Cloud Foundry self signed certificates
|
||||
By default, the security verification for `/cloudfoundryapplication` endpoints makes SSL
|
||||
calls to various Cloud Foundry services. If your Cloud Foundry UAA or Cloud Controller
|
||||
services use self-signed certificates you will need to set the following property:
|
||||
|
||||
.application.properties
|
||||
[source,properties,indent=0]
|
||||
----
|
||||
management.cloudfoundry.skip-ssl-validation=true
|
||||
----
|
||||
|
||||
|
||||
|
||||
[[production-ready-cloudfoundry-custom-security]]
|
||||
=== Custom security configuration
|
||||
If you define custom security configuration, and you want extended Cloud Foundry actuator
|
||||
support, you'll should ensure that `/cloudfoundryapplication/**` paths are open. Without
|
||||
a direct open route, your Cloud Foundry application manager will not be able to obtain
|
||||
endpoint data.
|
||||
|
||||
For Spring Security, you'll typically include something like
|
||||
`mvcMatchers("/cloudfoundryapplication/**").permitAll()` in your configuration:
|
||||
|
||||
[source,java,indent=0]
|
||||
----
|
||||
include::{code-examples}/cloudfoundry/CloudFoundryIgnorePathsExample.java[tag=security]
|
||||
----
|
||||
|
||||
|
||||
|
||||
[[production-ready-whats-next]]
|
||||
== What to read next
|
||||
If you want to explore some of the concepts discussed in this chapter, you can take a
|
||||
|
|
|
@ -1123,8 +1123,8 @@ Cloud Foundry you can add the following to your `manifest.yml`:
|
|||
[source,yaml,indent=0]
|
||||
----
|
||||
---
|
||||
env:
|
||||
JAVA_OPTS: "-Xdebug -Xrunjdwp:server=y,transport=dt_socket,suspend=n"
|
||||
env:
|
||||
JAVA_OPTS: "-Xdebug -Xrunjdwp:server=y,transport=dt_socket,suspend=n"
|
||||
----
|
||||
|
||||
TIP: Notice that you don't need to pass an `address=NNNN` option to `-Xrunjdwp`. If
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* Copyright 2012-2016 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.boot.cloudfoundry;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
|
||||
|
||||
/**
|
||||
* Example for custom Cloud Foundry actuator ignored paths.
|
||||
*
|
||||
* @author Phillip Webb
|
||||
*/
|
||||
public class CloudFoundryIgnorePathsExample {
|
||||
|
||||
@Configuration
|
||||
static class CustomSecurityConfiguration extends WebSecurityConfigurerAdapter {
|
||||
|
||||
// @formatter:off
|
||||
// tag::security[]
|
||||
@Override
|
||||
protected void configure(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.authorizeRequests()
|
||||
.mvcMatchers("/cloudfoundryapplication/**")
|
||||
.permitAll()
|
||||
.mvcMatchers("/mypath")
|
||||
.hasAnyRole("SUPERUSER")
|
||||
.anyRequest()
|
||||
.authenticated().and()
|
||||
.httpBasic();
|
||||
}
|
||||
// end::security[]
|
||||
// @formatter:on
|
||||
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue