Allow EndpointRequest to match additional paths

Add `toAdditionalPaths(...)` methods on the servlet and reactive
`EndpointRequest` classes to support matching of additional paths.

A new `AdditionalPathsMapper` interface provides the mappings between
endpoint IDs and any additional paths that they might use. The existing
`AutoConfiguredHealthEndpointGroups` class has been updated to implement
the interface.

Auto-configurations have also been updated so that additional health
endpoint paths (typically `/livez` and `/readyz`) are permitted
when using Spring Security without any custom configuration.

Fixes gh-40962
This commit is contained in:
Phillip Webb 2024-09-18 23:46:55 -07:00
parent f5b6514bef
commit d72a9d9eb5
26 changed files with 901 additions and 201 deletions

View File

@ -59,7 +59,7 @@ public class CloudFoundryWebEndpointDiscoverer extends WebEndpointDiscoverer {
ParameterValueMapper parameterValueMapper, EndpointMediaTypes endpointMediaTypes,
List<PathMapper> endpointPathMappers, Collection<OperationInvokerAdvisor> invokerAdvisors,
Collection<EndpointFilter<ExposableWebEndpoint>> filters) {
super(applicationContext, parameterValueMapper, endpointMediaTypes, endpointPathMappers, invokerAdvisors,
super(applicationContext, parameterValueMapper, endpointMediaTypes, endpointPathMappers, null, invokerAdvisors,
filters);
}

View File

@ -28,6 +28,7 @@ import org.springframework.boot.actuate.endpoint.EndpointsSupplier;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.invoke.OperationInvokerAdvisor;
import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper;
import org.springframework.boot.actuate.endpoint.web.AdditionalPathsMapper;
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoints;
@ -81,11 +82,12 @@ public class WebEndpointAutoConfiguration {
@ConditionalOnMissingBean(WebEndpointsSupplier.class)
public WebEndpointDiscoverer webEndpointDiscoverer(ParameterValueMapper parameterValueMapper,
EndpointMediaTypes endpointMediaTypes, ObjectProvider<PathMapper> endpointPathMappers,
ObjectProvider<AdditionalPathsMapper> additionalPathsMappers,
ObjectProvider<OperationInvokerAdvisor> invokerAdvisors,
ObjectProvider<EndpointFilter<ExposableWebEndpoint>> filters) {
return new WebEndpointDiscoverer(this.applicationContext, parameterValueMapper, endpointMediaTypes,
endpointPathMappers.orderedStream().toList(), invokerAdvisors.orderedStream().toList(),
filters.orderedStream().toList());
endpointPathMappers.orderedStream().toList(), additionalPathsMappers.orderedStream().toList(),
invokerAdvisors.orderedStream().toList(), filters.orderedStream().toList());
}
@Bean

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2022 the original author or authors.
* Copyright 2012-2024 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.
@ -21,9 +21,11 @@ import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Stream;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.BeanFactoryUtils;
@ -32,8 +34,12 @@ import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.annotation.BeanFactoryAnnotationUtils;
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointProperties.Group;
import org.springframework.boot.actuate.autoconfigure.health.HealthProperties.Status;
import org.springframework.boot.actuate.endpoint.EndpointId;
import org.springframework.boot.actuate.endpoint.Show;
import org.springframework.boot.actuate.endpoint.web.AdditionalPathsMapper;
import org.springframework.boot.actuate.endpoint.web.WebServerNamespace;
import org.springframework.boot.actuate.health.AdditionalHealthEndpointPath;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.actuate.health.HealthEndpointGroup;
import org.springframework.boot.actuate.health.HealthEndpointGroups;
import org.springframework.boot.actuate.health.HttpCodeStatusMapper;
@ -51,7 +57,7 @@ import org.springframework.util.ObjectUtils;
* @author Phillip Webb
* @author Madhura Bhave
*/
class AutoConfiguredHealthEndpointGroups implements HealthEndpointGroups {
class AutoConfiguredHealthEndpointGroups implements HealthEndpointGroups, AdditionalPathsMapper {
private static final Predicate<String> ALL = (name) -> true;
@ -159,4 +165,20 @@ class AutoConfiguredHealthEndpointGroups implements HealthEndpointGroups {
return this.groups.get(name);
}
@Override
public List<String> getAdditionalPaths(EndpointId endpointId, WebServerNamespace webServerNamespace) {
if (!HealthEndpoint.ID.equals(endpointId)) {
return null;
}
return streamAllGroups().map(HealthEndpointGroup::getAdditionalPath)
.filter(Objects::nonNull)
.filter((additionalPath) -> additionalPath.hasNamespace(webServerNamespace))
.map(AdditionalHealthEndpointPath::getValue)
.toList();
}
private Stream<HealthEndpointGroup> streamAllGroups() {
return Stream.concat(Stream.of(this.primaryGroup), this.groups.values().stream());
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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.
@ -73,8 +73,8 @@ class HealthEndpointConfiguration {
}
@Bean
@ConditionalOnMissingBean
HealthEndpointGroups healthEndpointGroups(ApplicationContext applicationContext,
@ConditionalOnMissingBean(HealthEndpointGroups.class)
AutoConfiguredHealthEndpointGroups healthEndpointGroups(ApplicationContext applicationContext,
HealthEndpointProperties properties) {
return new AutoConfiguredHealthEndpointGroups(applicationContext, properties);
}

View File

@ -35,6 +35,7 @@ import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortT
import org.springframework.boot.actuate.endpoint.EndpointId;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoints;
import org.springframework.boot.actuate.endpoint.web.WebServerNamespace;
import org.springframework.boot.security.reactive.ApplicationContextServerWebExchangeMatcher;
import org.springframework.context.ApplicationContext;
import org.springframework.core.annotation.MergedAnnotation;
@ -43,7 +44,9 @@ import org.springframework.security.web.server.util.matcher.OrServerWebExchangeM
import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
@ -52,6 +55,7 @@ import org.springframework.web.server.ServerWebExchange;
* endpoint locations.
*
* @author Madhura Bhave
* @author Phillip Webb
* @since 2.0.0
*/
public final class EndpointRequest {
@ -115,30 +119,129 @@ public final class EndpointRequest {
return new LinksServerWebExchangeMatcher();
}
/**
* Returns a matcher that includes additional paths under a {@link WebServerNamespace}
* for the specified {@link Endpoint actuator endpoints}. For example:
* <pre class="code">
* EndpointRequest.toAdditionalPaths(WebServerNamespace.SERVER, "health")
* </pre>
* @param webServerNamespace the web server namespace
* @param endpoints the endpoints to include
* @return the configured {@link RequestMatcher}
* @since 3.4.0
*/
public static AdditionalPathsEndpointServerWebExchangeMatcher toAdditionalPaths(
WebServerNamespace webServerNamespace, Class<?>... endpoints) {
return new AdditionalPathsEndpointServerWebExchangeMatcher(webServerNamespace, endpoints);
}
/**
* Returns a matcher that includes additional paths under a {@link WebServerNamespace}
* for the specified {@link Endpoint actuator endpoints}. For example:
* <pre class="code">
* EndpointRequest.toAdditionalPaths(WebServerNamespace.SERVER, HealthEndpoint.class)
* </pre>
* @param webServerNamespace the web server namespace
* @param endpoints the endpoints to include
* @return the configured {@link RequestMatcher}
* @since 3.4.0
*/
public static AdditionalPathsEndpointServerWebExchangeMatcher toAdditionalPaths(
WebServerNamespace webServerNamespace, String... endpoints) {
return new AdditionalPathsEndpointServerWebExchangeMatcher(webServerNamespace, endpoints);
}
/**
* Base class for supported request matchers.
*/
private abstract static class AbstractWebExchangeMatcher<T> extends ApplicationContextServerWebExchangeMatcher<T> {
private abstract static class AbstractWebExchangeMatcher<C> extends ApplicationContextServerWebExchangeMatcher<C> {
private ManagementPortType managementPortType;
private volatile ServerWebExchangeMatcher delegate;
AbstractWebExchangeMatcher(Class<? extends T> contextClass) {
private volatile ManagementPortType managementPortType;
AbstractWebExchangeMatcher(Class<? extends C> contextClass) {
super(contextClass);
}
@Override
protected void initialized(Supplier<C> supplier) {
this.delegate = createDelegate(supplier);
}
private ServerWebExchangeMatcher createDelegate(Supplier<C> context) {
try {
return createDelegate(context.get());
}
catch (NoSuchBeanDefinitionException ex) {
return EMPTY_MATCHER;
}
}
protected abstract ServerWebExchangeMatcher createDelegate(C context);
protected final List<ServerWebExchangeMatcher> getDelegateMatchers(Set<String> paths) {
return paths.stream().map(this::getDelegateMatcher).collect(Collectors.toCollection(ArrayList::new));
}
private PathPatternParserServerWebExchangeMatcher getDelegateMatcher(String path) {
return new PathPatternParserServerWebExchangeMatcher(path + "/**");
}
@Override
protected Mono<MatchResult> matches(ServerWebExchange exchange, Supplier<C> context) {
return this.delegate.matches(exchange);
}
@Override
protected boolean ignoreApplicationContext(ApplicationContext applicationContext) {
if (this.managementPortType == null) {
this.managementPortType = ManagementPortType.get(applicationContext.getEnvironment());
ManagementPortType managementPortType = this.managementPortType;
if (managementPortType == null) {
managementPortType = ManagementPortType.get(applicationContext.getEnvironment());
this.managementPortType = managementPortType;
}
if (this.managementPortType == ManagementPortType.DIFFERENT) {
if (applicationContext.getParent() == null) {
return true;
}
String managementContextId = applicationContext.getParent().getId() + ":management";
return !managementContextId.equals(applicationContext.getId());
return ignoreApplicationContext(applicationContext, managementPortType);
}
protected boolean ignoreApplicationContext(ApplicationContext applicationContext,
ManagementPortType managementPortType) {
return managementPortType == ManagementPortType.DIFFERENT
&& !hasWebServerNamespace(applicationContext, WebServerNamespace.MANAGEMENT);
}
protected final boolean hasWebServerNamespace(ApplicationContext applicationContext,
WebServerNamespace webServerNamespace) {
if (applicationContext.getParent() == null) {
return WebServerNamespace.SERVER.equals(webServerNamespace);
}
return false;
String parentContextId = applicationContext.getParent().getId();
return applicationContext.getId().equals(parentContextId + ":" + webServerNamespace);
}
protected final String toString(List<Object> endpoints, String emptyValue) {
return (!endpoints.isEmpty()) ? endpoints.stream()
.map(this::getEndpointId)
.map(Object::toString)
.collect(Collectors.joining(", ", "[", "]")) : emptyValue;
}
protected final EndpointId getEndpointId(Object source) {
if (source instanceof EndpointId endpointId) {
return endpointId;
}
if (source instanceof String string) {
return EndpointId.of(string);
}
if (source instanceof Class) {
return getEndpointId((Class<?>) source);
}
throw new IllegalStateException("Unsupported source " + source);
}
private EndpointId getEndpointId(Class<?> source) {
MergedAnnotation<Endpoint> annotation = MergedAnnotations.from(source).get(Endpoint.class);
Assert.state(annotation.isPresent(), () -> "Class " + source + " is not annotated with @Endpoint");
return EndpointId.of(annotation.getString("id"));
}
}
@ -155,8 +258,6 @@ public final class EndpointRequest {
private final boolean includeLinks;
private volatile ServerWebExchangeMatcher delegate;
private EndpointServerWebExchangeMatcher(boolean includeLinks) {
this(Collections.emptyList(), Collections.emptyList(), includeLinks);
}
@ -193,48 +294,22 @@ public final class EndpointRequest {
}
@Override
protected void initialized(Supplier<PathMappedEndpoints> pathMappedEndpoints) {
this.delegate = createDelegate(pathMappedEndpoints);
}
private ServerWebExchangeMatcher createDelegate(Supplier<PathMappedEndpoints> pathMappedEndpoints) {
try {
return createDelegate(pathMappedEndpoints.get());
}
catch (NoSuchBeanDefinitionException ex) {
return EMPTY_MATCHER;
}
}
private ServerWebExchangeMatcher createDelegate(PathMappedEndpoints pathMappedEndpoints) {
protected ServerWebExchangeMatcher createDelegate(PathMappedEndpoints endpoints) {
Set<String> paths = new LinkedHashSet<>();
if (this.includes.isEmpty()) {
paths.addAll(pathMappedEndpoints.getAllPaths());
paths.addAll(endpoints.getAllPaths());
}
streamPaths(this.includes, pathMappedEndpoints).forEach(paths::add);
streamPaths(this.excludes, pathMappedEndpoints).forEach(paths::remove);
streamPaths(this.includes, endpoints).forEach(paths::add);
streamPaths(this.excludes, endpoints).forEach(paths::remove);
List<ServerWebExchangeMatcher> delegateMatchers = getDelegateMatchers(paths);
if (this.includeLinks && StringUtils.hasText(pathMappedEndpoints.getBasePath())) {
if (this.includeLinks && StringUtils.hasText(endpoints.getBasePath())) {
delegateMatchers.add(new LinksServerWebExchangeMatcher());
}
return new OrServerWebExchangeMatcher(delegateMatchers);
}
private Stream<String> streamPaths(List<Object> source, PathMappedEndpoints pathMappedEndpoints) {
return source.stream().filter(Objects::nonNull).map(this::getEndpointId).map(pathMappedEndpoints::getPath);
}
@Override
protected Mono<MatchResult> matches(ServerWebExchange exchange, Supplier<PathMappedEndpoints> context) {
return this.delegate.matches(exchange);
}
private List<ServerWebExchangeMatcher> getDelegateMatchers(Set<String> paths) {
return paths.stream().map(this::getDelegateMatcher).collect(Collectors.toCollection(ArrayList::new));
}
private PathPatternParserServerWebExchangeMatcher getDelegateMatcher(String path) {
return new PathPatternParserServerWebExchangeMatcher(path + "/**");
private Stream<String> streamPaths(List<Object> source, PathMappedEndpoints endpoints) {
return source.stream().filter(Objects::nonNull).map(this::getEndpointId).map(endpoints::getPath);
}
@Override
@ -243,32 +318,6 @@ public final class EndpointRequest {
toString(this.includes, "[*]"), toString(this.excludes, "[]"), this.includeLinks);
}
private String toString(List<Object> endpoints, String emptyValue) {
return (!endpoints.isEmpty()) ? endpoints.stream()
.map(this::getEndpointId)
.map(Object::toString)
.collect(Collectors.joining(", ", "[", "]")) : emptyValue;
}
private EndpointId getEndpointId(Object source) {
if (source instanceof EndpointId endpointId) {
return endpointId;
}
if (source instanceof String string) {
return EndpointId.of(string);
}
if (source instanceof Class) {
return getEndpointId((Class<?>) source);
}
throw new IllegalStateException("Unsupported source " + source);
}
private EndpointId getEndpointId(Class<?> source) {
MergedAnnotation<Endpoint> annotation = MergedAnnotations.from(source).get(Endpoint.class);
Assert.state(annotation.isPresent(), () -> "Class " + source + " is not annotated with @Endpoint");
return EndpointId.of(annotation.getString("id"));
}
}
/**
@ -276,18 +325,12 @@ public final class EndpointRequest {
*/
public static final class LinksServerWebExchangeMatcher extends AbstractWebExchangeMatcher<WebEndpointProperties> {
private volatile ServerWebExchangeMatcher delegate;
private LinksServerWebExchangeMatcher() {
super(WebEndpointProperties.class);
}
@Override
protected void initialized(Supplier<WebEndpointProperties> properties) {
this.delegate = createDelegate(properties.get());
}
private ServerWebExchangeMatcher createDelegate(WebEndpointProperties properties) {
protected ServerWebExchangeMatcher createDelegate(WebEndpointProperties properties) {
if (StringUtils.hasText(properties.getBasePath())) {
return new OrServerWebExchangeMatcher(
new PathPatternParserServerWebExchangeMatcher(properties.getBasePath()),
@ -297,8 +340,67 @@ public final class EndpointRequest {
}
@Override
protected Mono<MatchResult> matches(ServerWebExchange exchange, Supplier<WebEndpointProperties> context) {
return this.delegate.matches(exchange);
public String toString() {
return String.format("LinksServerWebExchangeMatcher");
}
}
/**
* The {@link ServerWebExchangeMatcher} used to match against additional paths for
* {@link Endpoint actuator endpoints}.
*/
public static class AdditionalPathsEndpointServerWebExchangeMatcher
extends AbstractWebExchangeMatcher<PathMappedEndpoints> {
private final WebServerNamespace webServerNamespace;
private final List<Object> endpoints;
AdditionalPathsEndpointServerWebExchangeMatcher(WebServerNamespace webServerNamespace, String... endpoints) {
this(webServerNamespace, Arrays.asList((Object[]) endpoints));
}
AdditionalPathsEndpointServerWebExchangeMatcher(WebServerNamespace webServerNamespace, Class<?>... endpoints) {
this(webServerNamespace, Arrays.asList((Object[]) endpoints));
}
private AdditionalPathsEndpointServerWebExchangeMatcher(WebServerNamespace webServerNamespace,
List<Object> endpoints) {
super(PathMappedEndpoints.class);
Assert.notNull(webServerNamespace, "'webServerNamespace' must not be null");
Assert.notNull(endpoints, "'endpoints' must not be null");
Assert.notEmpty(endpoints, "'endpoints' must not be empty");
this.webServerNamespace = webServerNamespace;
this.endpoints = endpoints;
}
@Override
protected boolean ignoreApplicationContext(ApplicationContext applicationContext,
ManagementPortType managementPortType) {
return !hasWebServerNamespace(applicationContext, this.webServerNamespace);
}
@Override
protected ServerWebExchangeMatcher createDelegate(PathMappedEndpoints endpoints) {
Set<String> paths = this.endpoints.stream()
.filter(Objects::nonNull)
.map(this::getEndpointId)
.flatMap((endpointId) -> streamAdditionalPaths(endpoints, endpointId))
.collect(Collectors.toCollection(LinkedHashSet::new));
List<ServerWebExchangeMatcher> delegateMatchers = getDelegateMatchers(paths);
return (!CollectionUtils.isEmpty(delegateMatchers)) ? new OrServerWebExchangeMatcher(delegateMatchers)
: EMPTY_MATCHER;
}
private Stream<String> streamAdditionalPaths(PathMappedEndpoints pathMappedEndpoints, EndpointId endpointId) {
return pathMappedEndpoints.getAdditionalPaths(this.webServerNamespace, endpointId).stream();
}
@Override
public String toString() {
return String.format("AdditionalPathsEndpointServerWebExchangeMatcher endpoints=%s, webServerNamespace=%s",
toString(this.endpoints, ""), this.webServerNamespace);
}
}

View File

@ -21,6 +21,7 @@ import reactor.core.publisher.Mono;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.info.InfoEndpointAutoConfiguration;
import org.springframework.boot.actuate.endpoint.web.WebServerNamespace;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
@ -40,6 +41,7 @@ import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.WebFilterChainProxy;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
import org.springframework.web.cors.reactive.PreFlightRequestHandler;
import org.springframework.web.cors.reactive.PreFlightRequestWebFilter;
@ -66,7 +68,7 @@ public class ReactiveManagementWebSecurityAutoConfiguration {
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http, PreFlightRequestHandler handler) {
http.authorizeExchange((exchanges) -> {
exchanges.matchers(EndpointRequest.to(HealthEndpoint.class)).permitAll();
exchanges.matchers(healthMatcher(), additionalHealthPathsMatcher()).permitAll();
exchanges.anyExchange().authenticated();
});
PreFlightRequestWebFilter filter = new PreFlightRequestWebFilter(handler);
@ -76,6 +78,14 @@ public class ReactiveManagementWebSecurityAutoConfiguration {
return http.build();
}
private ServerWebExchangeMatcher healthMatcher() {
return EndpointRequest.to(HealthEndpoint.class);
}
private ServerWebExchangeMatcher additionalHealthPathsMatcher() {
return EndpointRequest.toAdditionalPaths(WebServerNamespace.SERVER, HealthEndpoint.class);
}
@Bean
@ConditionalOnMissingBean({ ReactiveAuthenticationManager.class, ReactiveUserDetailsService.class })
ReactiveAuthenticationManager denyAllAuthenticationManager() {

View File

@ -35,15 +35,18 @@ import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortT
import org.springframework.boot.actuate.endpoint.EndpointId;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoints;
import org.springframework.boot.actuate.endpoint.web.WebServerNamespace;
import org.springframework.boot.autoconfigure.security.servlet.RequestMatcherProvider;
import org.springframework.boot.security.servlet.ApplicationContextRequestMatcher;
import org.springframework.boot.web.context.WebServerApplicationContext;
import org.springframework.context.ApplicationContext;
import org.springframework.core.annotation.MergedAnnotation;
import org.springframework.core.annotation.MergedAnnotations;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.OrRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.context.WebApplicationContext;
@ -116,6 +119,38 @@ public final class EndpointRequest {
return new LinksRequestMatcher();
}
/**
* Returns a matcher that includes additional paths under a {@link WebServerNamespace}
* for the specified {@link Endpoint actuator endpoints}. For example:
* <pre class="code">
* EndpointRequest.toAdditionalPaths(WebServerNamespace.SERVER, "health")
* </pre>
* @param webServerNamespace the web server namespace
* @param endpoints the endpoints to include
* @return the configured {@link RequestMatcher}
* @since 3.4.0
*/
public static AdditionalPathsEndpointRequestMatcher toAdditionalPaths(WebServerNamespace webServerNamespace,
Class<?>... endpoints) {
return new AdditionalPathsEndpointRequestMatcher(webServerNamespace, endpoints);
}
/**
* Returns a matcher that includes additional paths under a {@link WebServerNamespace}
* for the specified {@link Endpoint actuator endpoints}. For example:
* <pre class="code">
* EndpointRequest.toAdditionalPaths(WebServerNamespace.SERVER, HealthEndpoint.class)
* </pre>
* @param webServerNamespace the web server namespace
* @param endpoints the endpoints to include
* @return the configured {@link RequestMatcher}
* @since 3.4.0
*/
public static AdditionalPathsEndpointRequestMatcher toAdditionalPaths(WebServerNamespace webServerNamespace,
String... endpoints) {
return new AdditionalPathsEndpointRequestMatcher(webServerNamespace, endpoints);
}
/**
* Base class for supported request matchers.
*/
@ -124,7 +159,7 @@ public final class EndpointRequest {
private volatile RequestMatcher delegate;
private ManagementPortType managementPortType;
private volatile ManagementPortType managementPortType;
AbstractRequestMatcher() {
super(WebApplicationContext.class);
@ -132,11 +167,25 @@ public final class EndpointRequest {
@Override
protected boolean ignoreApplicationContext(WebApplicationContext applicationContext) {
if (this.managementPortType == null) {
this.managementPortType = ManagementPortType.get(applicationContext.getEnvironment());
ManagementPortType managementPortType = this.managementPortType;
if (managementPortType == null) {
managementPortType = ManagementPortType.get(applicationContext.getEnvironment());
this.managementPortType = managementPortType;
}
return this.managementPortType == ManagementPortType.DIFFERENT
&& !WebServerApplicationContext.hasServerNamespace(applicationContext, "management");
return ignoreApplicationContext(applicationContext, managementPortType);
}
protected boolean ignoreApplicationContext(WebApplicationContext applicationContext,
ManagementPortType managementPortType) {
return managementPortType == ManagementPortType.DIFFERENT
&& !hasWebServerNamespace(applicationContext, WebServerNamespace.MANAGEMENT);
}
protected final boolean hasWebServerNamespace(ApplicationContext applicationContext,
WebServerNamespace webServerNamespace) {
return WebServerApplicationContext.hasServerNamespace(applicationContext, webServerNamespace.getValue())
|| (webServerNamespace.equals(WebServerNamespace.SERVER)
&& !(applicationContext instanceof WebServerApplicationContext));
}
@Override
@ -161,6 +210,13 @@ public final class EndpointRequest {
protected abstract RequestMatcher createDelegate(WebApplicationContext context,
RequestMatcherFactory requestMatcherFactory);
protected final List<RequestMatcher> getDelegateMatchers(RequestMatcherFactory requestMatcherFactory,
RequestMatcherProvider matcherProvider, Set<String> paths) {
return paths.stream()
.map((path) -> requestMatcherFactory.antPath(matcherProvider, path, "/**"))
.collect(Collectors.toCollection(ArrayList::new));
}
protected List<RequestMatcher> getLinksMatchers(RequestMatcherFactory requestMatcherFactory,
RequestMatcherProvider matcherProvider, String basePath) {
List<RequestMatcher> linksMatchers = new ArrayList<>();
@ -178,6 +234,32 @@ public final class EndpointRequest {
}
}
protected final String toString(List<Object> endpoints, String emptyValue) {
return (!endpoints.isEmpty()) ? endpoints.stream()
.map(this::getEndpointId)
.map(Object::toString)
.collect(Collectors.joining(", ", "[", "]")) : emptyValue;
}
protected final EndpointId getEndpointId(Object source) {
if (source instanceof EndpointId endpointId) {
return endpointId;
}
if (source instanceof String string) {
return EndpointId.of(string);
}
if (source instanceof Class<?> sourceClass) {
return getEndpointId(sourceClass);
}
throw new IllegalStateException("Unsupported source " + source);
}
private EndpointId getEndpointId(Class<?> source) {
MergedAnnotation<Endpoint> annotation = MergedAnnotations.from(source).get(Endpoint.class);
Assert.state(annotation.isPresent(), () -> "Class " + source + " is not annotated with @Endpoint");
return EndpointId.of(annotation.getString("id"));
}
}
/**
@ -228,31 +310,24 @@ public final class EndpointRequest {
@Override
protected RequestMatcher createDelegate(WebApplicationContext context,
RequestMatcherFactory requestMatcherFactory) {
PathMappedEndpoints pathMappedEndpoints = context.getBean(PathMappedEndpoints.class);
PathMappedEndpoints endpoints = context.getBean(PathMappedEndpoints.class);
RequestMatcherProvider matcherProvider = getRequestMatcherProvider(context);
Set<String> paths = new LinkedHashSet<>();
if (this.includes.isEmpty()) {
paths.addAll(pathMappedEndpoints.getAllPaths());
paths.addAll(endpoints.getAllPaths());
}
streamPaths(this.includes, pathMappedEndpoints).forEach(paths::add);
streamPaths(this.excludes, pathMappedEndpoints).forEach(paths::remove);
streamPaths(this.includes, endpoints).forEach(paths::add);
streamPaths(this.excludes, endpoints).forEach(paths::remove);
List<RequestMatcher> delegateMatchers = getDelegateMatchers(requestMatcherFactory, matcherProvider, paths);
String basePath = pathMappedEndpoints.getBasePath();
String basePath = endpoints.getBasePath();
if (this.includeLinks && StringUtils.hasText(basePath)) {
delegateMatchers.addAll(getLinksMatchers(requestMatcherFactory, matcherProvider, basePath));
}
return new OrRequestMatcher(delegateMatchers);
}
private Stream<String> streamPaths(List<Object> source, PathMappedEndpoints pathMappedEndpoints) {
return source.stream().filter(Objects::nonNull).map(this::getEndpointId).map(pathMappedEndpoints::getPath);
}
private List<RequestMatcher> getDelegateMatchers(RequestMatcherFactory requestMatcherFactory,
RequestMatcherProvider matcherProvider, Set<String> paths) {
return paths.stream()
.map((path) -> requestMatcherFactory.antPath(matcherProvider, path, "/**"))
.collect(Collectors.toCollection(ArrayList::new));
private Stream<String> streamPaths(List<Object> source, PathMappedEndpoints endpoints) {
return source.stream().filter(Objects::nonNull).map(this::getEndpointId).map(endpoints::getPath);
}
@Override
@ -261,32 +336,6 @@ public final class EndpointRequest {
toString(this.includes, "[*]"), toString(this.excludes, "[]"), this.includeLinks);
}
private String toString(List<Object> endpoints, String emptyValue) {
return (!endpoints.isEmpty()) ? endpoints.stream()
.map(this::getEndpointId)
.map(Object::toString)
.collect(Collectors.joining(", ", "[", "]")) : emptyValue;
}
private EndpointId getEndpointId(Object source) {
if (source instanceof EndpointId endpointId) {
return endpointId;
}
if (source instanceof String string) {
return EndpointId.of(string);
}
if (source instanceof Class) {
return getEndpointId((Class<?>) source);
}
throw new IllegalStateException("Unsupported source " + source);
}
private EndpointId getEndpointId(Class<?> source) {
MergedAnnotation<Endpoint> annotation = MergedAnnotations.from(source).get(Endpoint.class);
Assert.state(annotation.isPresent(), () -> "Class " + source + " is not annotated with @Endpoint");
return EndpointId.of(annotation.getString("id"));
}
}
/**
@ -306,6 +355,70 @@ public final class EndpointRequest {
return EMPTY_MATCHER;
}
@Override
public String toString() {
return String.format("LinksRequestMatcher");
}
}
/**
* The request matcher used to match against additional paths for {@link Endpoint
* actuator endpoints}.
*/
public static class AdditionalPathsEndpointRequestMatcher extends AbstractRequestMatcher {
private final WebServerNamespace webServerNamespace;
private final List<Object> endpoints;
AdditionalPathsEndpointRequestMatcher(WebServerNamespace webServerNamespace, String... endpoints) {
this(webServerNamespace, Arrays.asList((Object[]) endpoints));
}
AdditionalPathsEndpointRequestMatcher(WebServerNamespace webServerNamespace, Class<?>... endpoints) {
this(webServerNamespace, Arrays.asList((Object[]) endpoints));
}
private AdditionalPathsEndpointRequestMatcher(WebServerNamespace webServerNamespace, List<Object> endpoints) {
Assert.notNull(webServerNamespace, "'webServerNamespace' must not be null");
Assert.notNull(endpoints, "'endpoints' must not be null");
Assert.notEmpty(endpoints, "'endpoints' must not be empty");
this.webServerNamespace = webServerNamespace;
this.endpoints = endpoints;
}
@Override
protected boolean ignoreApplicationContext(WebApplicationContext applicationContext,
ManagementPortType managementPortType) {
return !hasWebServerNamespace(applicationContext, this.webServerNamespace);
}
@Override
protected RequestMatcher createDelegate(WebApplicationContext context,
RequestMatcherFactory requestMatcherFactory) {
PathMappedEndpoints endpoints = context.getBean(PathMappedEndpoints.class);
RequestMatcherProvider matcherProvider = getRequestMatcherProvider(context);
Set<String> paths = this.endpoints.stream()
.filter(Objects::nonNull)
.map(this::getEndpointId)
.flatMap((endpointId) -> streamAdditionalPaths(endpoints, endpointId))
.collect(Collectors.toCollection(LinkedHashSet::new));
List<RequestMatcher> delegateMatchers = getDelegateMatchers(requestMatcherFactory, matcherProvider, paths);
return (!CollectionUtils.isEmpty(delegateMatchers)) ? new OrRequestMatcher(delegateMatchers)
: EMPTY_MATCHER;
}
private Stream<String> streamAdditionalPaths(PathMappedEndpoints pathMappedEndpoints, EndpointId endpointId) {
return pathMappedEndpoints.getAdditionalPaths(this.webServerNamespace, endpointId).stream();
}
@Override
public String toString() {
return String.format("AdditionalPathsEndpointRequestMatcher endpoints=%s, webServerNamespace=%s",
toString(this.endpoints, ""), this.webServerNamespace);
}
}
/**

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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.
@ -19,6 +19,7 @@ package org.springframework.boot.actuate.autoconfigure.security.servlet;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.info.InfoEndpointAutoConfiguration;
import org.springframework.boot.actuate.endpoint.web.WebServerNamespace;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
@ -31,8 +32,10 @@ import org.springframework.boot.autoconfigure.security.saml2.Saml2RelyingPartyAu
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.Environment;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.ClassUtils;
import static org.springframework.security.config.Customizer.withDefaults;
@ -40,7 +43,7 @@ import static org.springframework.security.config.Customizer.withDefaults;
/**
* {@link EnableAutoConfiguration Auto-configuration} for Spring Security when actuator is
* on the classpath. It allows unauthenticated access to the {@link HealthEndpoint}. If
* the user specifies their own{@link SecurityFilterChain} bean, this will back-off
* the user specifies their own {@link SecurityFilterChain} bean, this will back-off
* completely and the user should specify all the bits that they want to configure as part
* of the custom security configuration.
*
@ -58,9 +61,9 @@ public class ManagementWebSecurityAutoConfiguration {
@Bean
@Order(SecurityProperties.BASIC_AUTH_ORDER)
SecurityFilterChain managementSecurityFilterChain(HttpSecurity http) throws Exception {
SecurityFilterChain managementSecurityFilterChain(Environment environment, HttpSecurity http) throws Exception {
http.authorizeHttpRequests((requests) -> {
requests.requestMatchers(EndpointRequest.to(HealthEndpoint.class)).permitAll();
requests.requestMatchers(healthMatcher(), additionalHealthPathsMatcher()).permitAll();
requests.anyRequest().authenticated();
});
if (ClassUtils.isPresent("org.springframework.web.servlet.DispatcherServlet", null)) {
@ -71,4 +74,12 @@ public class ManagementWebSecurityAutoConfiguration {
return http.build();
}
private RequestMatcher healthMatcher() {
return EndpointRequest.to(HealthEndpoint.class);
}
private RequestMatcher additionalHealthPathsMatcher() {
return EndpointRequest.toAdditionalPaths(WebServerNamespace.SERVER, HealthEndpoint.class);
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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.
@ -262,7 +262,7 @@ class CloudFoundryWebFluxEndpointIntegrationTests {
EndpointMediaTypes endpointMediaTypes) {
ParameterValueMapper parameterMapper = new ConversionServiceParameterValueMapper(
DefaultConversionService.getSharedInstance());
return new WebEndpointDiscoverer(applicationContext, parameterMapper, endpointMediaTypes, null,
return new WebEndpointDiscoverer(applicationContext, parameterMapper, endpointMediaTypes, null, null,
Collections.emptyList(), Collections.emptyList());
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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.
@ -256,7 +256,7 @@ class CloudFoundryMvcWebEndpointIntegrationTests {
EndpointMediaTypes endpointMediaTypes) {
ParameterValueMapper parameterMapper = new ConversionServiceParameterValueMapper(
DefaultConversionService.getSharedInstance());
return new WebEndpointDiscoverer(applicationContext, parameterMapper, endpointMediaTypes, null,
return new WebEndpointDiscoverer(applicationContext, parameterMapper, endpointMediaTypes, null, null,
Collections.emptyList(), Collections.emptyList());
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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.
@ -21,7 +21,11 @@ import java.util.Collections;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.actuate.endpoint.EndpointId;
import org.springframework.boot.actuate.endpoint.SecurityContext;
import org.springframework.boot.actuate.endpoint.web.AdditionalPathsMapper;
import org.springframework.boot.actuate.endpoint.web.WebServerNamespace;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.actuate.health.HealthEndpointGroup;
import org.springframework.boot.actuate.health.HealthEndpointGroups;
import org.springframework.boot.actuate.health.HttpCodeStatusMapper;
@ -333,6 +337,24 @@ class AutoConfiguredHealthEndpointGroupsTests {
});
}
@Test
void getAdditionalPathsReturnsAllAdditionalPaths() {
this.contextRunner
.withPropertyValues("management.endpoint.health.group.a.additional-path=server:/a",
"management.endpoint.health.group.b.additional-path=server:/b",
"management.endpoint.health.group.c.additional-path=management:/c",
"management.endpoint.health.group.d.additional-path=management:/d")
.run((context) -> {
AdditionalPathsMapper additionalPathsMapper = context.getBean(AdditionalPathsMapper.class);
assertThat(additionalPathsMapper.getAdditionalPaths(HealthEndpoint.ID, WebServerNamespace.SERVER))
.containsExactlyInAnyOrder("/a", "/b");
assertThat(additionalPathsMapper.getAdditionalPaths(HealthEndpoint.ID, WebServerNamespace.MANAGEMENT))
.containsExactlyInAnyOrder("/c", "/d");
assertThat(additionalPathsMapper.getAdditionalPaths(EndpointId.of("other"), WebServerNamespace.SERVER))
.isNull();
});
}
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(HealthEndpointProperties.class)
static class AutoConfiguredHealthEndpointGroupsTestConfiguration {

View File

@ -18,6 +18,7 @@ package org.springframework.boot.actuate.autoconfigure.security.reactive;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.assertj.core.api.AssertDelegateTarget;
@ -30,6 +31,7 @@ import org.springframework.boot.actuate.endpoint.Operation;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoint;
import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoints;
import org.springframework.boot.actuate.endpoint.web.WebServerNamespace;
import org.springframework.context.support.StaticApplicationContext;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
@ -234,6 +236,13 @@ class EndpointRequestTests {
assertThat(matcher).hasToString("EndpointRequestMatcher includes=[*], excludes=[bar], includeLinks=false");
}
@Test
void toStringWhenToAdditionalPaths() {
ServerWebExchangeMatcher matcher = EndpointRequest.toAdditionalPaths(WebServerNamespace.SERVER, "test");
assertThat(matcher)
.hasToString("AdditionalPathsEndpointServerWebExchangeMatcher endpoints=[test], webServerNamespace=server");
}
@Test
void toAnyEndpointWhenEndpointPathMappedToRootIsExcludedShouldNotMatchRoot() {
ServerWebExchangeMatcher matcher = EndpointRequest.toAnyEndpoint().excluding("root");
@ -252,6 +261,43 @@ class EndpointRequestTests {
assertMatcher.matches("/");
}
@Test
void toAdditionalPathsWithEndpointClassShouldMatchAdditionalPath() {
ServerWebExchangeMatcher matcher = EndpointRequest.toAdditionalPaths(WebServerNamespace.SERVER,
FooEndpoint.class);
RequestMatcherAssert assertMatcher = assertMatcher(matcher, new PathMappedEndpoints("",
() -> List.of(mockEndpoint(EndpointId.of("foo"), "test", WebServerNamespace.SERVER, "/additional"))));
assertMatcher.matches("/additional");
}
@Test
void toAdditionalPathsWithEndpointIdShouldMatchAdditionalPath() {
ServerWebExchangeMatcher matcher = EndpointRequest.toAdditionalPaths(WebServerNamespace.SERVER, "foo");
RequestMatcherAssert assertMatcher = assertMatcher(matcher, new PathMappedEndpoints("",
() -> List.of(mockEndpoint(EndpointId.of("foo"), "test", WebServerNamespace.SERVER, "/additional"))));
assertMatcher.matches("/additional");
}
@Test
void toAdditionalPathsWithEndpointClassShouldNotMatchOtherPaths() {
ServerWebExchangeMatcher matcher = EndpointRequest.toAdditionalPaths(WebServerNamespace.SERVER,
FooEndpoint.class);
RequestMatcherAssert assertMatcher = assertMatcher(matcher, new PathMappedEndpoints("",
() -> List.of(mockEndpoint(EndpointId.of("foo"), "test", WebServerNamespace.SERVER, "/additional"))));
assertMatcher.doesNotMatch("/foo");
assertMatcher.doesNotMatch("/bar");
}
@Test
void toAdditionalPathsWithEndpointClassShouldNotMatchOtherNamespace() {
ServerWebExchangeMatcher matcher = EndpointRequest.toAdditionalPaths(WebServerNamespace.SERVER,
FooEndpoint.class);
RequestMatcherAssert assertMatcher = assertMatcher(matcher, new PathMappedEndpoints("",
() -> List.of(mockEndpoint(EndpointId.of("foo"), "test", WebServerNamespace.SERVER, "/additional"))),
WebServerNamespace.MANAGEMENT);
assertMatcher.doesNotMatch("/additional");
}
private RequestMatcherAssert assertMatcher(ServerWebExchangeMatcher matcher) {
return assertMatcher(matcher, mockPathMappedEndpoints("/actuator"));
}
@ -260,6 +306,31 @@ class EndpointRequestTests {
return assertMatcher(matcher, mockPathMappedEndpoints(basePath));
}
private RequestMatcherAssert assertMatcher(ServerWebExchangeMatcher matcher,
PathMappedEndpoints pathMappedEndpoints) {
return assertMatcher(matcher, pathMappedEndpoints, null);
}
private RequestMatcherAssert assertMatcher(ServerWebExchangeMatcher matcher,
PathMappedEndpoints pathMappedEndpoints, WebServerNamespace namespace) {
StaticApplicationContext context = new StaticApplicationContext();
if (namespace != null && !WebServerNamespace.SERVER.equals(namespace)) {
StaticApplicationContext parentContext = new StaticApplicationContext();
parentContext.setId("app");
context.setParent(parentContext);
context.setId(parentContext.getId() + ":" + namespace);
}
context.registerBean(WebEndpointProperties.class);
if (pathMappedEndpoints != null) {
context.registerBean(PathMappedEndpoints.class, () -> pathMappedEndpoints);
WebEndpointProperties properties = context.getBean(WebEndpointProperties.class);
if (!properties.getBasePath().equals(pathMappedEndpoints.getBasePath())) {
properties.setBasePath(pathMappedEndpoints.getBasePath());
}
}
return assertThat(new RequestMatcherAssert(context, matcher));
}
private PathMappedEndpoints mockPathMappedEndpoints(String basePath) {
List<ExposableEndpoint<?>> endpoints = new ArrayList<>();
endpoints.add(mockEndpoint(EndpointId.of("foo"), "foo"));
@ -268,26 +339,18 @@ class EndpointRequestTests {
}
private TestEndpoint mockEndpoint(EndpointId id, String rootPath) {
return mockEndpoint(id, rootPath, WebServerNamespace.SERVER);
}
private TestEndpoint mockEndpoint(EndpointId id, String rootPath, WebServerNamespace webServerNamespace,
String... additionalPaths) {
TestEndpoint endpoint = mock(TestEndpoint.class);
given(endpoint.getEndpointId()).willReturn(id);
given(endpoint.getRootPath()).willReturn(rootPath);
given(endpoint.getAdditionalPaths(webServerNamespace)).willReturn(Arrays.asList(additionalPaths));
return endpoint;
}
private RequestMatcherAssert assertMatcher(ServerWebExchangeMatcher matcher,
PathMappedEndpoints pathMappedEndpoints) {
StaticApplicationContext context = new StaticApplicationContext();
context.registerBean(WebEndpointProperties.class);
if (pathMappedEndpoints != null) {
context.registerBean(PathMappedEndpoints.class, () -> pathMappedEndpoints);
WebEndpointProperties properties = context.getBean(WebEndpointProperties.class);
if (!properties.getBasePath().equals(pathMappedEndpoints.getBasePath())) {
properties.setBasePath(pathMappedEndpoints.getBasePath());
}
}
return assertThat(new RequestMatcherAssert(context, matcher));
}
static class RequestMatcherAssert implements AssertDelegateTarget {
private final StaticApplicationContext context;

View File

@ -79,6 +79,35 @@ class ReactiveManagementWebSecurityAutoConfigurationTests {
.run((context) -> assertThat(getAuthenticateHeader(context, "/actuator/health")).isNull());
}
@Test
void withAdditionalPathsOnSamePort() {
this.contextRunner.withUserConfiguration(UserDetailsServiceConfiguration.class)
.withPropertyValues("management.endpoint.health.group.test1.include=*",
"management.endpoint.health.group.test2.include=*",
"management.endpoint.health.group.test1.additional-path=server:/check1",
"management.endpoint.health.group.test2.additional-path=management:/check2")
.run((context) -> {
assertThat(getAuthenticateHeader(context, "/check1")).isNull();
assertThat(getAuthenticateHeader(context, "/check2").get(0)).contains("Basic realm=");
assertThat(getAuthenticateHeader(context, "/actuator/health")).isNull();
});
}
@Test
void withAdditionalPathsOnDifferentPort() {
this.contextRunner.withUserConfiguration(UserDetailsServiceConfiguration.class)
.withPropertyValues("management.endpoint.health.group.test1.include=*",
"management.endpoint.health.group.test2.include=*",
"management.endpoint.health.group.test1.additional-path=server:/check1",
"management.endpoint.health.group.test2.additional-path=management:/check2",
"management.server.port=0")
.run((context) -> {
assertThat(getAuthenticateHeader(context, "/check1")).isNull();
assertThat(getAuthenticateHeader(context, "/check2").get(0)).contains("Basic realm=");
assertThat(getAuthenticateHeader(context, "/actuator/health").get(0)).contains("Basic realm=");
});
}
@Test
void securesEverythingElse() {
this.contextRunner.withUserConfiguration(UserDetailsServiceConfiguration.class).run((context) -> {

View File

@ -17,6 +17,7 @@
package org.springframework.boot.actuate.autoconfigure.security.servlet;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import jakarta.servlet.http.HttpServletRequest;
@ -24,6 +25,7 @@ import org.assertj.core.api.AssertDelegateTarget;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointProperties;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest.AdditionalPathsEndpointRequestMatcher;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest.EndpointRequestMatcher;
import org.springframework.boot.actuate.endpoint.EndpointId;
import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
@ -31,7 +33,10 @@ import org.springframework.boot.actuate.endpoint.Operation;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoint;
import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoints;
import org.springframework.boot.actuate.endpoint.web.WebServerNamespace;
import org.springframework.boot.autoconfigure.security.servlet.RequestMatcherProvider;
import org.springframework.boot.web.context.WebServerApplicationContext;
import org.springframework.boot.web.server.WebServer;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockServletContext;
import org.springframework.security.web.util.matcher.RequestMatcher;
@ -194,7 +199,7 @@ class EndpointRequestTests {
RequestMatcher matcher = EndpointRequest.toAnyEndpoint();
RequestMatcher mockRequestMatcher = (request) -> false;
RequestMatcherAssert assertMatcher = assertMatcher(matcher, mockPathMappedEndpoints(""),
(pattern) -> mockRequestMatcher);
(pattern) -> mockRequestMatcher, null);
assertMatcher.doesNotMatch("/foo");
assertMatcher.doesNotMatch("/bar");
}
@ -204,7 +209,7 @@ class EndpointRequestTests {
RequestMatcher matcher = EndpointRequest.toLinks();
RequestMatcher mockRequestMatcher = (request) -> false;
RequestMatcherAssert assertMatcher = assertMatcher(matcher, mockPathMappedEndpoints("/actuator"),
(pattern) -> mockRequestMatcher);
(pattern) -> mockRequestMatcher, null);
assertMatcher.doesNotMatch("/actuator");
}
@ -239,6 +244,13 @@ class EndpointRequestTests {
assertThat(matcher).hasToString("EndpointRequestMatcher includes=[*], excludes=[bar], includeLinks=false");
}
@Test
void toStringWhenToAdditionalPaths() {
RequestMatcher matcher = EndpointRequest.toAdditionalPaths(WebServerNamespace.SERVER, "test");
assertThat(matcher)
.hasToString("AdditionalPathsEndpointRequestMatcher endpoints=[test], webServerNamespace=server");
}
@Test
void toAnyEndpointWhenEndpointPathMappedToRootIsExcludedShouldNotMatchRoot() {
EndpointRequestMatcher matcher = EndpointRequest.toAnyEndpoint().excluding("root");
@ -257,12 +269,50 @@ class EndpointRequestTests {
assertMatcher.matches("/");
}
@Test
void toAdditionalPathsWithEndpointClassShouldMatchAdditionalPath() {
AdditionalPathsEndpointRequestMatcher matcher = EndpointRequest.toAdditionalPaths(WebServerNamespace.SERVER,
FooEndpoint.class);
RequestMatcherAssert assertMatcher = assertMatcher(matcher, new PathMappedEndpoints("",
() -> List.of(mockEndpoint(EndpointId.of("foo"), "test", WebServerNamespace.SERVER, "/additional"))));
assertMatcher.matches("/additional");
}
@Test
void toAdditionalPathsWithEndpointIdShouldMatchAdditionalPath() {
AdditionalPathsEndpointRequestMatcher matcher = EndpointRequest.toAdditionalPaths(WebServerNamespace.SERVER,
"foo");
RequestMatcherAssert assertMatcher = assertMatcher(matcher, new PathMappedEndpoints("",
() -> List.of(mockEndpoint(EndpointId.of("foo"), "test", WebServerNamespace.SERVER, "/additional"))));
assertMatcher.matches("/additional");
}
@Test
void toAdditionalPathsWithEndpointClassShouldNotMatchOtherPaths() {
AdditionalPathsEndpointRequestMatcher matcher = EndpointRequest.toAdditionalPaths(WebServerNamespace.SERVER,
FooEndpoint.class);
RequestMatcherAssert assertMatcher = assertMatcher(matcher, new PathMappedEndpoints("",
() -> List.of(mockEndpoint(EndpointId.of("foo"), "test", WebServerNamespace.SERVER, "/additional"))));
assertMatcher.doesNotMatch("/foo");
assertMatcher.doesNotMatch("/bar");
}
@Test
void toAdditionalPathsWithEndpointClassShouldNotMatchOtherNamespace() {
AdditionalPathsEndpointRequestMatcher matcher = EndpointRequest.toAdditionalPaths(WebServerNamespace.SERVER,
FooEndpoint.class);
RequestMatcherAssert assertMatcher = assertMatcher(matcher, new PathMappedEndpoints("",
() -> List.of(mockEndpoint(EndpointId.of("foo"), "test", WebServerNamespace.SERVER, "/additional"))),
null, WebServerNamespace.MANAGEMENT);
assertMatcher.doesNotMatch("/additional");
}
private RequestMatcherAssert assertMatcher(RequestMatcher matcher) {
return assertMatcher(matcher, mockPathMappedEndpoints("/actuator"));
}
private RequestMatcherAssert assertMatcher(RequestMatcher matcher, String basePath) {
return assertMatcher(matcher, mockPathMappedEndpoints(basePath), null);
return assertMatcher(matcher, mockPathMappedEndpoints(basePath), null, null);
}
private PathMappedEndpoints mockPathMappedEndpoints(String basePath) {
@ -273,19 +323,26 @@ class EndpointRequestTests {
}
private TestEndpoint mockEndpoint(EndpointId id, String rootPath) {
return mockEndpoint(id, rootPath, WebServerNamespace.SERVER);
}
private TestEndpoint mockEndpoint(EndpointId id, String rootPath, WebServerNamespace webServerNamespace,
String... additionalPaths) {
TestEndpoint endpoint = mock(TestEndpoint.class);
given(endpoint.getEndpointId()).willReturn(id);
given(endpoint.getRootPath()).willReturn(rootPath);
given(endpoint.getAdditionalPaths(webServerNamespace)).willReturn(Arrays.asList(additionalPaths));
return endpoint;
}
private RequestMatcherAssert assertMatcher(RequestMatcher matcher, PathMappedEndpoints pathMappedEndpoints) {
return assertMatcher(matcher, pathMappedEndpoints, null);
return assertMatcher(matcher, pathMappedEndpoints, null, null);
}
private RequestMatcherAssert assertMatcher(RequestMatcher matcher, PathMappedEndpoints pathMappedEndpoints,
RequestMatcherProvider matcherProvider) {
StaticWebApplicationContext context = new StaticWebApplicationContext();
RequestMatcherProvider matcherProvider, WebServerNamespace webServerNamespace) {
StaticWebApplicationContext context = (webServerNamespace != null)
? new NamedStaticWebApplicationContext(webServerNamespace) : new StaticWebApplicationContext();
context.registerBean(WebEndpointProperties.class);
if (pathMappedEndpoints != null) {
context.registerBean(PathMappedEndpoints.class, () -> pathMappedEndpoints);
@ -300,6 +357,27 @@ class EndpointRequestTests {
return assertThat(new RequestMatcherAssert(context, matcher));
}
static class NamedStaticWebApplicationContext extends StaticWebApplicationContext
implements WebServerApplicationContext {
private final WebServerNamespace webServerNamespace;
NamedStaticWebApplicationContext(WebServerNamespace webServerNamespace) {
this.webServerNamespace = webServerNamespace;
}
@Override
public WebServer getWebServer() {
return null;
}
@Override
public String getServerNamespace() {
return this.webServerNamespace.getValue();
}
}
static class RequestMatcherAssert implements AssertDelegateTarget {
private final WebApplicationContext context;

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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.
@ -18,6 +18,7 @@ package org.springframework.boot.actuate.autoconfigure.security.servlet;
import java.io.IOException;
import java.util.List;
import java.util.function.Supplier;
import org.junit.jupiter.api.Test;
@ -36,6 +37,9 @@ import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguratio
import org.springframework.boot.test.context.FilteredClassLoader;
import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.boot.web.context.WebServerApplicationContext;
import org.springframework.boot.web.server.WebServer;
import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
@ -48,6 +52,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.web.context.ConfigurableWebApplicationContext;
import org.springframework.web.context.WebApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
@ -63,11 +68,17 @@ class ManagementWebSecurityAutoConfigurationTests {
private static final String MANAGEMENT_SECURITY_FILTER_CHAIN_BEAN = "managementSecurityFilterChain";
private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner().withConfiguration(
AutoConfigurations.of(HealthContributorAutoConfiguration.class, HealthEndpointAutoConfiguration.class,
InfoEndpointAutoConfiguration.class, EnvironmentEndpointAutoConfiguration.class,
EndpointAutoConfiguration.class, WebMvcAutoConfiguration.class, WebEndpointAutoConfiguration.class,
SecurityAutoConfiguration.class, ManagementWebSecurityAutoConfiguration.class));
private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner(contextSupplier(),
WebServerApplicationContext.class)
.withConfiguration(AutoConfigurations.of(HealthContributorAutoConfiguration.class,
HealthEndpointAutoConfiguration.class, InfoEndpointAutoConfiguration.class,
EnvironmentEndpointAutoConfiguration.class, EndpointAutoConfiguration.class,
WebMvcAutoConfiguration.class, WebEndpointAutoConfiguration.class, SecurityAutoConfiguration.class,
ManagementWebSecurityAutoConfiguration.class));
private static Supplier<ConfigurableWebApplicationContext> contextSupplier() {
return WebApplicationContextRunner.withMockServletContext(MockWebServerApplicationContext::new);
}
@Test
void permitAllForHealth() {
@ -159,6 +170,33 @@ class ManagementWebSecurityAutoConfigurationTests {
});
}
@Test
void withAdditionalPathsOnSamePort() {
this.contextRunner
.withPropertyValues("management.endpoint.health.group.test1.include=*",
"management.endpoint.health.group.test2.include=*",
"management.endpoint.health.group.test1.additional-path=server:/check1",
"management.endpoint.health.group.test2.additional-path=management:/check2")
.run((context) -> {
assertThat(getResponseStatus(context, "/check1")).isEqualTo(HttpStatus.OK);
assertThat(getResponseStatus(context, "/check2")).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(getResponseStatus(context, "/actuator/health")).isEqualTo(HttpStatus.OK);
});
}
@Test
void withAdditionalPathsOnDifferentPort() {
this.contextRunner.withPropertyValues("management.endpoint.health.group.test1.include=*",
"management.endpoint.health.group.test2.include=*",
"management.endpoint.health.group.test1.additional-path=server:/check1",
"management.endpoint.health.group.test2.additional-path=management:/check2", "management.server.port=0")
.run((context) -> {
assertThat(getResponseStatus(context, "/check1")).isEqualTo(HttpStatus.OK);
assertThat(getResponseStatus(context, "/check2")).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(getResponseStatus(context, "/actuator/health")).isEqualTo(HttpStatus.UNAUTHORIZED);
});
}
private HttpStatus getResponseStatus(AssertableWebApplicationContext context, String path)
throws IOException, jakarta.servlet.ServletException {
FilterChainProxy filterChainProxy = context.getBean(FilterChainProxy.class);
@ -214,4 +252,19 @@ class ManagementWebSecurityAutoConfigurationTests {
}
static class MockWebServerApplicationContext extends AnnotationConfigServletWebApplicationContext
implements WebServerApplicationContext {
@Override
public WebServer getWebServer() {
return null;
}
@Override
public String getServerNamespace() {
return "server";
}
}
}

View File

@ -0,0 +1,43 @@
/*
* Copyright 2012-2024 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
*
* https://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.actuate.endpoint.web;
import java.util.List;
import org.springframework.boot.actuate.endpoint.EndpointId;
/**
* Strategy interface used to provide a mapping between an endpoint ID and any additional
* paths where it will be exposed.
*
* @author Phillip Webb
* @since 3.4.0
*/
@FunctionalInterface
public interface AdditionalPathsMapper {
/**
* Resolve the additional paths for the specified {@code endpointId} and web server
* namespace.
* @param endpointId the id of an endpoint
* @param webServerNamespace the web server namespace
* @return the additional paths of the endpoint or {@code null} if this mapper doesn't
* support the given endpoint ID.
*/
List<String> getAdditionalPaths(EndpointId endpointId, WebServerNamespace webServerNamespace);
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2019 the original author or authors.
* Copyright 2012-2024 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.
@ -16,6 +16,9 @@
package org.springframework.boot.actuate.endpoint.web;
import java.util.Collections;
import java.util.List;
import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
/**
@ -30,11 +33,23 @@ import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
public interface PathMappedEndpoint {
/**
* Return the root path of the endpoint, relative to the context that exposes it. For
* example, a root path of {@code example} would be exposed under the URL
* "/{actuator-context}/example".
* Return the root path of the endpoint (relative to the context and base path) that
* exposes it. For example, a root path of {@code example} would be exposed under the
* URL "/{actuator-context}/example".
* @return the root path for the endpoint
* @see PathMappedEndpoints#getBasePath
*/
String getRootPath();
/**
* Return any additional paths (relative to the context) for the given
* {@link WebServerNamespace}.
* @param webServerNamespace the web server namespace
* @return a list of additional paths
* @since 3.4.0
*/
default List<String> getAdditionalPaths(WebServerNamespace webServerNamespace) {
return Collections.emptyList();
}
}

View File

@ -20,12 +20,14 @@ import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.springframework.boot.actuate.endpoint.EndpointId;
import org.springframework.boot.actuate.endpoint.EndpointsSupplier;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
/**
* A collection of {@link PathMappedEndpoint path mapped endpoints}.
@ -101,7 +103,7 @@ public class PathMappedEndpoints implements Iterable<PathMappedEndpoint> {
}
/**
* Return the root paths for each mapped endpoint.
* Return the root paths for each mapped endpoint (excluding additional paths).
* @return all root paths
*/
public Collection<String> getAllRootPaths() {
@ -109,13 +111,36 @@ public class PathMappedEndpoints implements Iterable<PathMappedEndpoint> {
}
/**
* Return the full paths for each mapped endpoint.
* Return the full paths for each mapped endpoint (excluding additional paths).
* @return all root paths
*/
public Collection<String> getAllPaths() {
return stream().map(this::getPath).toList();
}
/**
* Return the additional paths for each mapped endpoint.
* @param webServerNamespace the web server namespace
* @param endpointId the endpoint ID
* @return all additional paths
* @since 3.4.0
*/
public Collection<String> getAdditionalPaths(WebServerNamespace webServerNamespace, EndpointId endpointId) {
return getAdditionalPaths(webServerNamespace, getEndpoint(endpointId)).toList();
}
private Stream<String> getAdditionalPaths(WebServerNamespace webServerNamespace, PathMappedEndpoint endpoint) {
List<String> additionalPaths = (endpoint != null) ? endpoint.getAdditionalPaths(webServerNamespace) : null;
if (CollectionUtils.isEmpty(additionalPaths)) {
return Stream.empty();
}
return additionalPaths.stream().map(this::getAdditionalPath);
}
private String getAdditionalPath(String path) {
return path.startsWith("/") ? path : "/" + path;
}
/**
* Return the {@link PathMappedEndpoint} with the given ID or {@code null} if the
* endpoint cannot be found.

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2021 the original author or authors.
* Copyright 2012-2024 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.
@ -19,7 +19,8 @@ package org.springframework.boot.actuate.endpoint.web;
import org.springframework.util.StringUtils;
/**
* Enumeration of server namespaces.
* A web server namespace used for disambiguation when multiple web servers are running in
* the same application (for example a management context running on a different port).
*
* @author Phillip Webb
* @author Madhura Bhave
@ -43,17 +44,14 @@ public final class WebServerNamespace {
this.value = value;
}
/**
* Return the value of the namespace.
* @return the value
*/
public String getValue() {
return this.value;
}
public static WebServerNamespace from(String value) {
if (StringUtils.hasText(value)) {
return new WebServerNamespace(value);
}
return SERVER;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
@ -71,4 +69,22 @@ public final class WebServerNamespace {
return this.value.hashCode();
}
@Override
public String toString() {
return this.value;
}
/**
* Factory method to create a new {@link WebServerNamespace} from a value. If the
* value is empty or {@code null} then {@link #SERVER} is returned.
* @param value the namespace value or {@code null}
* @return the web server namespace
*/
public static WebServerNamespace from(String value) {
if (StringUtils.hasText(value)) {
return new WebServerNamespace(value);
}
return SERVER;
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2019 the original author or authors.
* Copyright 2012-2024 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.
@ -17,12 +17,16 @@
package org.springframework.boot.actuate.endpoint.web.annotation;
import java.util.Collection;
import java.util.List;
import java.util.stream.Stream;
import org.springframework.boot.actuate.endpoint.EndpointId;
import org.springframework.boot.actuate.endpoint.annotation.AbstractDiscoveredEndpoint;
import org.springframework.boot.actuate.endpoint.annotation.EndpointDiscoverer;
import org.springframework.boot.actuate.endpoint.web.AdditionalPathsMapper;
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
import org.springframework.boot.actuate.endpoint.web.WebOperation;
import org.springframework.boot.actuate.endpoint.web.WebServerNamespace;
/**
* A discovered {@link ExposableWebEndpoint web endpoint}.
@ -33,10 +37,14 @@ class DiscoveredWebEndpoint extends AbstractDiscoveredEndpoint<WebOperation> imp
private final String rootPath;
private Collection<AdditionalPathsMapper> additionalPathsMappers;
DiscoveredWebEndpoint(EndpointDiscoverer<?, ?> discoverer, Object endpointBean, EndpointId id, String rootPath,
boolean enabledByDefault, Collection<WebOperation> operations) {
boolean enabledByDefault, Collection<WebOperation> operations,
Collection<AdditionalPathsMapper> additionalPathsMappers) {
super(discoverer, endpointBean, id, enabledByDefault, operations);
this.rootPath = rootPath;
this.additionalPathsMappers = additionalPathsMappers;
}
@Override
@ -44,4 +52,15 @@ class DiscoveredWebEndpoint extends AbstractDiscoveredEndpoint<WebOperation> imp
return this.rootPath;
}
@Override
public List<String> getAdditionalPaths(WebServerNamespace webServerNamespace) {
return this.additionalPathsMappers.stream()
.flatMap((mapper) -> getAdditionalPaths(webServerNamespace, mapper))
.toList();
}
private Stream<String> getAdditionalPaths(WebServerNamespace webServerNamespace, AdditionalPathsMapper mapper) {
return mapper.getAdditionalPaths(getEndpointId(), webServerNamespace).stream();
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2022 the original author or authors.
* Copyright 2012-2024 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.
@ -17,6 +17,7 @@
package org.springframework.boot.actuate.endpoint.web.annotation;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import org.springframework.aot.hint.MemberCategory;
@ -29,6 +30,7 @@ import org.springframework.boot.actuate.endpoint.annotation.EndpointDiscoverer;
import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker;
import org.springframework.boot.actuate.endpoint.invoke.OperationInvokerAdvisor;
import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper;
import org.springframework.boot.actuate.endpoint.web.AdditionalPathsMapper;
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
import org.springframework.boot.actuate.endpoint.web.PathMapper;
@ -51,6 +53,8 @@ public class WebEndpointDiscoverer extends EndpointDiscoverer<ExposableWebEndpoi
private final List<PathMapper> endpointPathMappers;
private final List<AdditionalPathsMapper> additionalPathsMappers;
private final RequestPredicateFactory requestPredicateFactory;
/**
@ -61,13 +65,37 @@ public class WebEndpointDiscoverer extends EndpointDiscoverer<ExposableWebEndpoi
* @param endpointPathMappers the endpoint path mappers
* @param invokerAdvisors invoker advisors to apply
* @param filters filters to apply
* @deprecated since 3.4.0 for removal in 3.6.0 in favor of
* {@link #WebEndpointDiscoverer(ApplicationContext, ParameterValueMapper, EndpointMediaTypes, List, List, Collection, Collection)}
*/
@Deprecated(since = "3.4.0", forRemoval = true)
public WebEndpointDiscoverer(ApplicationContext applicationContext, ParameterValueMapper parameterValueMapper,
EndpointMediaTypes endpointMediaTypes, List<PathMapper> endpointPathMappers,
Collection<OperationInvokerAdvisor> invokerAdvisors,
Collection<EndpointFilter<ExposableWebEndpoint>> filters) {
this(applicationContext, parameterValueMapper, endpointMediaTypes, endpointPathMappers, Collections.emptyList(),
invokerAdvisors, filters);
}
/**
* Create a new {@link WebEndpointDiscoverer} instance.
* @param applicationContext the source application context
* @param parameterValueMapper the parameter value mapper
* @param endpointMediaTypes the endpoint media types
* @param endpointPathMappers the endpoint path mappers
* @param additionalPathsMappers the
* @param invokerAdvisors invoker advisors to apply
* @param filters filters to apply
* @since 3.4.0
*/
public WebEndpointDiscoverer(ApplicationContext applicationContext, ParameterValueMapper parameterValueMapper,
EndpointMediaTypes endpointMediaTypes, List<PathMapper> endpointPathMappers,
List<AdditionalPathsMapper> additionalPathsMappers, Collection<OperationInvokerAdvisor> invokerAdvisors,
Collection<EndpointFilter<ExposableWebEndpoint>> filters) {
super(applicationContext, parameterValueMapper, invokerAdvisors, filters);
this.endpointPathMappers = endpointPathMappers;
this.endpointPathMappers = (endpointPathMappers != null) ? endpointPathMappers : Collections.emptyList();
this.additionalPathsMappers = (additionalPathsMappers != null) ? additionalPathsMappers
: Collections.emptyList();
this.requestPredicateFactory = new RequestPredicateFactory(endpointMediaTypes);
}
@ -75,7 +103,8 @@ public class WebEndpointDiscoverer extends EndpointDiscoverer<ExposableWebEndpoi
protected ExposableWebEndpoint createEndpoint(Object endpointBean, EndpointId id, boolean enabledByDefault,
Collection<WebOperation> operations) {
String rootPath = PathMapper.getRootPath(this.endpointPathMappers, id);
return new DiscoveredWebEndpoint(this, endpointBean, id, rootPath, enabledByDefault, operations);
return new DiscoveredWebEndpoint(this, endpointBean, id, rootPath, enabledByDefault, operations,
this.additionalPathsMappers);
}
@Override

View File

@ -129,19 +129,36 @@ class PathMappedEndpointsTests {
assertThat(mapped.getEndpoint(EndpointId.of("xx"))).isNull();
}
@Test
void getAdditionalPathsShouldReturnCanonicalAdditionalPaths() {
PathMappedEndpoints mapped = createTestMapped(null);
assertThat(mapped.getAdditionalPaths(WebServerNamespace.SERVER, EndpointId.of("e2"))).containsExactly("/a2",
"/A2");
assertThat(mapped.getAdditionalPaths(WebServerNamespace.MANAGEMENT, EndpointId.of("e2"))).isEmpty();
assertThat(mapped.getAdditionalPaths(WebServerNamespace.SERVER, EndpointId.of("e3"))).isEmpty();
}
private PathMappedEndpoints createTestMapped(String basePath) {
List<ExposableEndpoint<?>> endpoints = new ArrayList<>();
endpoints.add(mockEndpoint(EndpointId.of("e1")));
endpoints.add(mockEndpoint(EndpointId.of("e2"), "p2"));
endpoints.add(mockEndpoint(EndpointId.of("e2"), "p2", WebServerNamespace.SERVER, List.of("/a2", "A2")));
endpoints.add(mockEndpoint(EndpointId.of("e3"), "p3"));
endpoints.add(mockEndpoint(EndpointId.of("e4")));
return new PathMappedEndpoints(basePath, () -> endpoints);
}
private TestPathMappedEndpoint mockEndpoint(EndpointId id, String rootPath) {
return mockEndpoint(id, rootPath, null, null);
}
private TestPathMappedEndpoint mockEndpoint(EndpointId id, String rootPath, WebServerNamespace webServerNamespace,
List<String> additionalPaths) {
TestPathMappedEndpoint endpoint = mock(TestPathMappedEndpoint.class);
given(endpoint.getEndpointId()).willReturn(id);
given(endpoint.getRootPath()).willReturn(rootPath);
if (webServerNamespace != null && additionalPaths != null) {
given(endpoint.getAdditionalPaths(webServerNamespace)).willReturn(additionalPaths);
}
return endpoint;
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2021 the original author or authors.
* Copyright 2012-2024 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.
@ -53,4 +53,9 @@ class WebServerNamespaceTests {
assertThat(WebServerNamespace.from("value")).isNotEqualTo(WebServerNamespace.from("other"));
}
@Test
void toStringReturnsString() {
assertThat(WebServerNamespace.from("value")).hasToString("value");
}
}

View File

@ -68,7 +68,8 @@ class BaseConfiguration {
ParameterValueMapper parameterMapper = new ConversionServiceParameterValueMapper(
DefaultConversionService.getSharedInstance());
return new WebEndpointDiscoverer(applicationContext, parameterMapper, endpointMediaTypes,
pathMappers.orderedStream().toList(), Collections.emptyList(), Collections.emptyList());
pathMappers.orderedStream().toList(), Collections.emptyList(), Collections.emptyList(),
Collections.emptyList());
}
@Bean

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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.
@ -43,12 +43,14 @@ import org.springframework.boot.actuate.endpoint.invoke.convert.ConversionServic
import org.springframework.boot.actuate.endpoint.invoker.cache.CachingOperationInvoker;
import org.springframework.boot.actuate.endpoint.invoker.cache.CachingOperationInvokerAdvisor;
import org.springframework.boot.actuate.endpoint.jmx.annotation.JmxEndpoint;
import org.springframework.boot.actuate.endpoint.web.AdditionalPathsMapper;
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
import org.springframework.boot.actuate.endpoint.web.PathMapper;
import org.springframework.boot.actuate.endpoint.web.WebEndpointHttpMethod;
import org.springframework.boot.actuate.endpoint.web.WebOperation;
import org.springframework.boot.actuate.endpoint.web.WebOperationRequestPredicate;
import org.springframework.boot.actuate.endpoint.web.WebServerNamespace;
import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpointDiscoverer.WebEndpointDiscovererRuntimeHints;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
@ -223,6 +225,23 @@ class WebEndpointDiscovererTests {
});
}
@Test
void getEndpointsWhenHasAdditionalPaths() {
AdditionalPathsMapper additionalPathsMapper = (id, webServerNamespace) -> {
if (!WebServerNamespace.SERVER.equals(webServerNamespace)) {
return Collections.emptyList();
}
return List.of("/test");
};
load((id) -> null, EndpointId::toString, additionalPathsMapper,
AdditionalOperationWebEndpointConfiguration.class, (discoverer) -> {
Map<EndpointId, ExposableWebEndpoint> endpoints = mapEndpoints(discoverer.getEndpoints());
ExposableWebEndpoint endpoint = endpoints.get(EndpointId.of("test"));
assertThat(endpoint.getAdditionalPaths(WebServerNamespace.SERVER)).containsExactly("/test");
assertThat(endpoint.getAdditionalPaths(WebServerNamespace.MANAGEMENT)).isEmpty();
});
}
@Test
void shouldRegisterHints() {
RuntimeHints runtimeHints = new RuntimeHints();
@ -230,7 +249,6 @@ class WebEndpointDiscovererTests {
assertThat(RuntimeHintsPredicates.reflection()
.onType(WebEndpointFilter.class)
.withMemberCategories(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS)).accepts(runtimeHints);
}
private void load(Class<?> configuration, Consumer<WebEndpointDiscoverer> consumer) {
@ -239,6 +257,12 @@ class WebEndpointDiscovererTests {
private void load(Function<EndpointId, Long> timeToLive, PathMapper endpointPathMapper, Class<?> configuration,
Consumer<WebEndpointDiscoverer> consumer) {
load(timeToLive, endpointPathMapper, null, configuration, consumer);
}
private void load(Function<EndpointId, Long> timeToLive, PathMapper endpointPathMapper,
AdditionalPathsMapper additionalPathsMapper, Class<?> configuration,
Consumer<WebEndpointDiscoverer> consumer) {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(configuration)) {
ConversionServiceParameterValueMapper parameterMapper = new ConversionServiceParameterValueMapper(
DefaultConversionService.getSharedInstance());
@ -246,6 +270,7 @@ class WebEndpointDiscovererTests {
Collections.singletonList("application/json"));
WebEndpointDiscoverer discoverer = new WebEndpointDiscoverer(context, parameterMapper, mediaTypes,
Collections.singletonList(endpointPathMapper),
(additionalPathsMapper != null) ? Collections.singletonList(additionalPathsMapper) : null,
Collections.singleton(new CachingOperationInvokerAdvisor(timeToLive)), Collections.emptyList());
consumer.accept(discoverer);
}

View File

@ -243,7 +243,7 @@ class WebEndpointTestInvocationContextProvider implements TestTemplateInvocation
EndpointMediaTypes endpointMediaTypes = EndpointMediaTypes.DEFAULT;
WebEndpointDiscoverer discoverer = new WebEndpointDiscoverer(this.applicationContext,
new ConversionServiceParameterValueMapper(), endpointMediaTypes, null, Collections.emptyList(),
Collections.emptyList());
Collections.emptyList(), Collections.emptyList());
Collection<Resource> resources = new JerseyEndpointResourceFactory().createEndpointResources(
new EndpointMapping("/actuator"), discoverer.getEndpoints(), endpointMediaTypes,
new EndpointLinksResolver(discoverer.getEndpoints()), true);
@ -288,8 +288,8 @@ class WebEndpointTestInvocationContextProvider implements TestTemplateInvocation
WebFluxEndpointHandlerMapping webEndpointReactiveHandlerMapping() {
EndpointMediaTypes endpointMediaTypes = EndpointMediaTypes.DEFAULT;
WebEndpointDiscoverer discoverer = new WebEndpointDiscoverer(this.applicationContext,
new ConversionServiceParameterValueMapper(), endpointMediaTypes, null, Collections.emptyList(),
Collections.emptyList());
new ConversionServiceParameterValueMapper(), endpointMediaTypes, Collections.emptyList(),
Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
return new WebFluxEndpointHandlerMapping(new EndpointMapping("/actuator"), discoverer.getEndpoints(),
endpointMediaTypes, new CorsConfiguration(), new EndpointLinksResolver(discoverer.getEndpoints()),
true);
@ -317,8 +317,8 @@ class WebEndpointTestInvocationContextProvider implements TestTemplateInvocation
WebMvcEndpointHandlerMapping webEndpointServletHandlerMapping() {
EndpointMediaTypes endpointMediaTypes = EndpointMediaTypes.DEFAULT;
WebEndpointDiscoverer discoverer = new WebEndpointDiscoverer(this.applicationContext,
new ConversionServiceParameterValueMapper(), endpointMediaTypes, null, Collections.emptyList(),
Collections.emptyList());
new ConversionServiceParameterValueMapper(), endpointMediaTypes, Collections.emptyList(),
Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
return new WebMvcEndpointHandlerMapping(new EndpointMapping("/actuator"), discoverer.getEndpoints(),
endpointMediaTypes, new CorsConfiguration(), new EndpointLinksResolver(discoverer.getEndpoints()),
true);