Skip to content

Commit 25082d3

Browse files
committed
Provide more control over access to endpoint operations
This commit reworks the support for enabling and disabling endpoints, replacing the on/off support that it provided with a finer-grained access model that supports only allowing read-only access to endpoint operations in addition to disabling an endpoint (access of none) and fully enabling it (access of unrestricted). The following properties are deprecated: - management.endpoints.enabled-by-default - management.endpoint.<id>.enabled Their replacements are: - management.endpoints.access.default - management.endpoint.<id>.access Similarly, the enableByDefault attribute on @endpoint has been deprecated with a new defaultAccess attribute replacing it. Additionally, a new property has been introduced that allows an operator to control the level of access to Actuator endpoints that is permitted: - management.endpoints.access.max-permitted This property caps any access that may has been configured for an endpoint. For example, if management.endpoints.access.max-permitted is set to read-only and management.endpoint.loggers.access is set to unrestricted, only read-only access to the loggers endpoint will be allowed. Closes gh-39046
1 parent 4ce9141 commit 25082d3

File tree

84 files changed

+2568
-215
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

84 files changed

+2568
-215
lines changed

spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointDiscoverer.java

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,21 @@
1717
package org.springframework.boot.actuate.autoconfigure.cloudfoundry;
1818

1919
import java.util.Collection;
20+
import java.util.Collections;
2021
import java.util.List;
2122

2223
import org.springframework.aot.hint.MemberCategory;
2324
import org.springframework.aot.hint.RuntimeHints;
2425
import org.springframework.aot.hint.RuntimeHintsRegistrar;
2526
import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryWebEndpointDiscoverer.CloudFoundryWebEndpointDiscovererRuntimeHints;
2627
import org.springframework.boot.actuate.endpoint.EndpointFilter;
28+
import org.springframework.boot.actuate.endpoint.OperationFilter;
2729
import org.springframework.boot.actuate.endpoint.invoke.OperationInvokerAdvisor;
2830
import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper;
2931
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
3032
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
3133
import org.springframework.boot.actuate.endpoint.web.PathMapper;
34+
import org.springframework.boot.actuate.endpoint.web.WebOperation;
3235
import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension;
3336
import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpointDiscoverer;
3437
import org.springframework.boot.actuate.health.HealthEndpoint;
@@ -53,14 +56,37 @@ public class CloudFoundryWebEndpointDiscoverer extends WebEndpointDiscoverer {
5356
* @param endpointMediaTypes the endpoint media types
5457
* @param endpointPathMappers the endpoint path mappers
5558
* @param invokerAdvisors invoker advisors to apply
56-
* @param filters filters to apply
59+
* @param endpointFilters endpoint filters to apply
60+
* @deprecated since 3.4.0 for removal in 3.6.0 in favor of
61+
* {@link #CloudFoundryWebEndpointDiscoverer(ApplicationContext, ParameterValueMapper, EndpointMediaTypes, List, Collection, Collection, Collection)}
5762
*/
63+
@Deprecated(since = "3.4.0", forRemoval = true)
5864
public CloudFoundryWebEndpointDiscoverer(ApplicationContext applicationContext,
5965
ParameterValueMapper parameterValueMapper, EndpointMediaTypes endpointMediaTypes,
6066
List<PathMapper> endpointPathMappers, Collection<OperationInvokerAdvisor> invokerAdvisors,
61-
Collection<EndpointFilter<ExposableWebEndpoint>> filters) {
67+
Collection<EndpointFilter<ExposableWebEndpoint>> endpointFilters) {
68+
this(applicationContext, parameterValueMapper, endpointMediaTypes, endpointPathMappers, invokerAdvisors,
69+
endpointFilters, Collections.emptyList());
70+
}
71+
72+
/**
73+
* Create a new {@link WebEndpointDiscoverer} instance.
74+
* @param applicationContext the source application context
75+
* @param parameterValueMapper the parameter value mapper
76+
* @param endpointMediaTypes the endpoint media types
77+
* @param endpointPathMappers the endpoint path mappers
78+
* @param invokerAdvisors invoker advisors to apply
79+
* @param endpointFilters endpoint filters to apply
80+
* @param operationFilters operation filters to apply
81+
* @since 3.4.0
82+
*/
83+
public CloudFoundryWebEndpointDiscoverer(ApplicationContext applicationContext,
84+
ParameterValueMapper parameterValueMapper, EndpointMediaTypes endpointMediaTypes,
85+
List<PathMapper> endpointPathMappers, Collection<OperationInvokerAdvisor> invokerAdvisors,
86+
Collection<EndpointFilter<ExposableWebEndpoint>> endpointFilters,
87+
Collection<OperationFilter<WebOperation>> operationFilters) {
6288
super(applicationContext, parameterValueMapper, endpointMediaTypes, endpointPathMappers, null, invokerAdvisors,
63-
filters);
89+
endpointFilters, operationFilters);
6490
}
6591

6692
@Override

spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfiguration.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ public CloudFoundryWebFluxEndpointHandlerMapping cloudFoundryWebFluxEndpointHand
113113
org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointsSupplier controllerEndpointsSupplier,
114114
ApplicationContext applicationContext) {
115115
CloudFoundryWebEndpointDiscoverer endpointDiscoverer = new CloudFoundryWebEndpointDiscoverer(applicationContext,
116-
parameterMapper, endpointMediaTypes, null, Collections.emptyList(), Collections.emptyList());
116+
parameterMapper, endpointMediaTypes, null, Collections.emptyList(), Collections.emptyList(),
117+
Collections.emptyList());
117118
CloudFoundrySecurityInterceptor securityInterceptor = getSecurityInterceptor(webClientBuilder,
118119
applicationContext.getEnvironment());
119120
Collection<ExposableWebEndpoint> webEndpoints = endpointDiscoverer.getEndpoints();

spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfiguration.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,8 @@ public CloudFoundryWebEndpointServletHandlerMapping cloudFoundryWebEndpointServl
117117
org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointsSupplier controllerEndpointsSupplier,
118118
ApplicationContext applicationContext) {
119119
CloudFoundryWebEndpointDiscoverer discoverer = new CloudFoundryWebEndpointDiscoverer(applicationContext,
120-
parameterMapper, endpointMediaTypes, null, Collections.emptyList(), Collections.emptyList());
120+
parameterMapper, endpointMediaTypes, null, Collections.emptyList(), Collections.emptyList(),
121+
Collections.emptyList());
121122
CloudFoundrySecurityInterceptor securityInterceptor = getSecurityInterceptor(restTemplateBuilder,
122123
applicationContext.getEnvironment());
123124
Collection<ExposableWebEndpoint> webEndpoints = discoverer.getEndpoints();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Copyright 2012-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.actuate.autoconfigure.endpoint;
18+
19+
import org.springframework.boot.actuate.endpoint.Access;
20+
import org.springframework.boot.actuate.endpoint.EndpointAccessResolver;
21+
import org.springframework.boot.actuate.endpoint.EndpointId;
22+
import org.springframework.boot.actuate.endpoint.Operation;
23+
import org.springframework.boot.actuate.endpoint.OperationFilter;
24+
import org.springframework.boot.actuate.endpoint.OperationType;
25+
26+
/**
27+
* An {@link OperationFilter} that filters based on the allowed {@link Access access} as
28+
* determined by an {@link EndpointAccessResolver access resolver}.
29+
*
30+
* @param <O> the operation type
31+
* @author Andy Wilkinson
32+
* @since 3.4.0
33+
*/
34+
public class EndpointAccessOperationFilter<O extends Operation> implements OperationFilter<O> {
35+
36+
private final EndpointAccessResolver accessResolver;
37+
38+
public EndpointAccessOperationFilter(EndpointAccessResolver accessResolver) {
39+
this.accessResolver = accessResolver;
40+
}
41+
42+
@Override
43+
public boolean match(O operation, EndpointId endpointId, Access defaultAccess) {
44+
Access access = this.accessResolver.accessFor(endpointId, defaultAccess);
45+
return switch (access) {
46+
case NONE -> false;
47+
case READ_ONLY -> operation.getType() == OperationType.READ;
48+
case UNRESTRICTED -> true;
49+
};
50+
}
51+
52+
}

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2022 the original author or authors.
2+
* Copyright 2012-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -19,6 +19,7 @@
1919
import java.util.List;
2020

2121
import org.springframework.beans.factory.ObjectProvider;
22+
import org.springframework.boot.actuate.endpoint.EndpointAccessResolver;
2223
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
2324
import org.springframework.boot.actuate.endpoint.annotation.EndpointConverter;
2425
import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper;
@@ -73,4 +74,10 @@ public CachingOperationInvokerAdvisor endpointCachingOperationInvokerAdvisor(Env
7374
return new CachingOperationInvokerAdvisor(new EndpointIdTimeToLivePropertyFunction(environment));
7475
}
7576

77+
@Bean
78+
@ConditionalOnMissingBean(EndpointAccessResolver.class)
79+
PropertiesEndpointAccessResolver propertiesEndpointAccessResolver(Environment environment) {
80+
return new PropertiesEndpointAccessResolver(environment);
81+
}
82+
7683
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
* Copyright 2012-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.actuate.autoconfigure.endpoint;
18+
19+
import java.util.Map;
20+
import java.util.concurrent.ConcurrentHashMap;
21+
22+
import org.springframework.boot.actuate.endpoint.Access;
23+
import org.springframework.boot.actuate.endpoint.EndpointAccessResolver;
24+
import org.springframework.boot.actuate.endpoint.EndpointId;
25+
import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException;
26+
import org.springframework.core.env.PropertyResolver;
27+
28+
/**
29+
* {@link EndpointAccessResolver} that resolves the permitted level of access to an
30+
* endpoint using the following properties:
31+
* <ol>
32+
* <li>{@code management.endpoint.<id>.access} or {@code management.endpoint.<id>.enabled}
33+
* (deprecated)
34+
* <li>{@code management.endpoints.access.default} or
35+
* {@code management.endpoints.enabled-by-default} (deprecated)
36+
* </ol>
37+
* The resulting access is capped using {@code management.endpoints.access.max-permitted}.
38+
*
39+
* @author Andy Wilkinson
40+
* @since 3.4.0
41+
*/
42+
public class PropertiesEndpointAccessResolver implements EndpointAccessResolver {
43+
44+
private static final String DEFAULT_ACCESS_KEY = "management.endpoints.access.default";
45+
46+
private static final String ENABLED_BY_DEFAULT_KEY = "management.endpoints.enabled-by-default";
47+
48+
private final PropertyResolver properties;
49+
50+
private final Access endpointsDefaultAccess;
51+
52+
private final Access maxPermittedAccess;
53+
54+
private final Map<EndpointId, Access> accessCache = new ConcurrentHashMap<>();
55+
56+
public PropertiesEndpointAccessResolver(PropertyResolver properties) {
57+
this.properties = properties;
58+
this.endpointsDefaultAccess = determineDefaultAccess(properties);
59+
this.maxPermittedAccess = properties.getProperty("management.endpoints.access.max-permitted", Access.class,
60+
Access.UNRESTRICTED);
61+
}
62+
63+
private static Access determineDefaultAccess(PropertyResolver properties) {
64+
Access defaultAccess = properties.getProperty(DEFAULT_ACCESS_KEY, Access.class);
65+
Boolean endpointsEnabledByDefault = properties.getProperty(ENABLED_BY_DEFAULT_KEY, Boolean.class);
66+
MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> {
67+
entries.put(DEFAULT_ACCESS_KEY, defaultAccess);
68+
entries.put(ENABLED_BY_DEFAULT_KEY, endpointsEnabledByDefault);
69+
});
70+
if (defaultAccess != null) {
71+
return defaultAccess;
72+
}
73+
if (endpointsEnabledByDefault != null) {
74+
return endpointsEnabledByDefault ? org.springframework.boot.actuate.endpoint.Access.UNRESTRICTED
75+
: org.springframework.boot.actuate.endpoint.Access.NONE;
76+
}
77+
return null;
78+
}
79+
80+
@Override
81+
public Access accessFor(EndpointId endpointId, Access defaultAccess) {
82+
return this.accessCache.computeIfAbsent(endpointId,
83+
(key) -> capAccess(resolveAccess(endpointId, defaultAccess)));
84+
}
85+
86+
private Access resolveAccess(EndpointId endpointId, Access defaultAccess) {
87+
String accessKey = "management.endpoint.%s.access".formatted(endpointId);
88+
String enabledKey = "management.endpoint.%s.enabled".formatted(endpointId);
89+
Access access = this.properties.getProperty(accessKey, Access.class);
90+
Boolean enabled = this.properties.getProperty(enabledKey, Boolean.class);
91+
MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> {
92+
entries.put(accessKey, access);
93+
entries.put(enabledKey, enabled);
94+
});
95+
if (access != null) {
96+
return access;
97+
}
98+
if (enabled != null) {
99+
return (enabled) ? Access.UNRESTRICTED : Access.NONE;
100+
}
101+
return (this.endpointsDefaultAccess != null) ? this.endpointsDefaultAccess : defaultAccess;
102+
}
103+
104+
private Access capAccess(Access access) {
105+
return Access.values()[Math.min(access.ordinal(), this.maxPermittedAccess.ordinal())];
106+
}
107+
108+
}

spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/condition/OnAvailableEndpointCondition.java

Lines changed: 22 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@
2323
import java.util.List;
2424
import java.util.Locale;
2525
import java.util.Map;
26-
import java.util.Optional;
2726
import java.util.Set;
2827

28+
import org.springframework.boot.actuate.autoconfigure.endpoint.PropertiesEndpointAccessResolver;
2929
import org.springframework.boot.actuate.autoconfigure.endpoint.expose.EndpointExposure;
3030
import org.springframework.boot.actuate.autoconfigure.endpoint.expose.IncludeExcludeEndpointFilter;
31+
import org.springframework.boot.actuate.endpoint.Access;
32+
import org.springframework.boot.actuate.endpoint.EndpointAccessResolver;
3133
import org.springframework.boot.actuate.endpoint.EndpointId;
3234
import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
3335
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
@@ -51,7 +53,7 @@
5153
import org.springframework.util.ConcurrentReferenceHashMap;
5254

5355
/**
54-
* A condition that checks if an endpoint is available (i.e. enabled and exposed).
56+
* A condition that checks if an endpoint is available (i.e. accessible and exposed).
5557
*
5658
* @author Brian Clozel
5759
* @author Stephane Nicoll
@@ -63,12 +65,10 @@ class OnAvailableEndpointCondition extends SpringBootCondition {
6365

6466
private static final String JMX_ENABLED_KEY = "spring.jmx.enabled";
6567

66-
private static final String ENABLED_BY_DEFAULT_KEY = "management.endpoints.enabled-by-default";
68+
private static final Map<Environment, EndpointAccessResolver> accessResolversCache = new ConcurrentReferenceHashMap<>();
6769

6870
private static final Map<Environment, Set<EndpointExposureOutcomeContributor>> exposureOutcomeContributorsCache = new ConcurrentReferenceHashMap<>();
6971

70-
private static final ConcurrentReferenceHashMap<Environment, Optional<Boolean>> enabledByDefaultCache = new ConcurrentReferenceHashMap<>();
71-
7272
@Override
7373
public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
7474
Environment environment = context.getEnvironment();
@@ -114,35 +114,27 @@ private ConditionOutcome getMatchOutcome(Environment environment,
114114
MergedAnnotation<Endpoint> endpointAnnotation) {
115115
ConditionMessage.Builder message = ConditionMessage.forCondition(ConditionalOnAvailableEndpoint.class);
116116
EndpointId endpointId = EndpointId.of(environment, endpointAnnotation.getString("id"));
117-
ConditionOutcome enablementOutcome = getEnablementOutcome(environment, endpointAnnotation, endpointId, message);
118-
ConditionOutcome exposureOutcome = (!enablementOutcome.isMatch()) ? null
119-
: getExposureOutcome(environment, conditionAnnotation, endpointAnnotation, endpointId, message);
120-
return (exposureOutcome != null) ? exposureOutcome
121-
: ConditionOutcome.noMatch(message.because("not enabled or exposed"));
117+
ConditionOutcome accessOutcome = getAccessOutcome(environment, endpointAnnotation, endpointId, message);
118+
if (!accessOutcome.isMatch()) {
119+
return accessOutcome;
120+
}
121+
ConditionOutcome exposureOutcome = getExposureOutcome(environment, conditionAnnotation, endpointAnnotation,
122+
endpointId, message);
123+
return (exposureOutcome != null) ? exposureOutcome : ConditionOutcome.noMatch(message.because("not exposed"));
122124
}
123125

124-
private ConditionOutcome getEnablementOutcome(Environment environment,
125-
MergedAnnotation<Endpoint> endpointAnnotation, EndpointId endpointId, ConditionMessage.Builder message) {
126-
String key = "management.endpoint." + endpointId.toLowerCaseString() + ".enabled";
127-
Boolean userDefinedEnabled = environment.getProperty(key, Boolean.class);
128-
if (userDefinedEnabled != null) {
129-
return new ConditionOutcome(userDefinedEnabled,
130-
message.because("found property " + key + " with value " + userDefinedEnabled));
131-
}
132-
Boolean userDefinedDefault = isEnabledByDefault(environment);
133-
if (userDefinedDefault != null) {
134-
return new ConditionOutcome(userDefinedDefault, message
135-
.because("no property " + key + " found so using user defined default from " + ENABLED_BY_DEFAULT_KEY));
136-
}
137-
boolean endpointDefault = endpointAnnotation.getBoolean("enableByDefault");
138-
return new ConditionOutcome(endpointDefault,
139-
message.because("no property " + key + " found so using endpoint default of " + endpointDefault));
126+
private ConditionOutcome getAccessOutcome(Environment environment, MergedAnnotation<Endpoint> endpointAnnotation,
127+
EndpointId endpointId, ConditionMessage.Builder message) {
128+
Access defaultAccess = endpointAnnotation.getEnum("defaultAccess", Access.class);
129+
boolean enableByDefault = endpointAnnotation.getBoolean("enableByDefault");
130+
Access access = getAccess(environment, endpointId, (enableByDefault) ? defaultAccess : Access.NONE);
131+
return new ConditionOutcome(access != Access.NONE,
132+
message.because("the configured access for endpoint '%s' is %s".formatted(endpointId, access)));
140133
}
141134

142-
private Boolean isEnabledByDefault(Environment environment) {
143-
Optional<Boolean> enabledByDefault = enabledByDefaultCache.computeIfAbsent(environment,
144-
(ignore) -> Optional.ofNullable(environment.getProperty(ENABLED_BY_DEFAULT_KEY, Boolean.class)));
145-
return enabledByDefault.orElse(null);
135+
private Access getAccess(Environment environment, EndpointId endpointId, Access defaultAccess) {
136+
return accessResolversCache.computeIfAbsent(environment, PropertiesEndpointAccessResolver::new)
137+
.accessFor(endpointId, defaultAccess);
146138
}
147139

148140
private ConditionOutcome getExposureOutcome(Environment environment,

0 commit comments

Comments
 (0)