Provide end-to-end traceability for config properties
Closes gh-14880 Co-authored-by: Andy Wilkinson <awilkinson@pivotal.io>
This commit is contained in:
@@ -22,6 +22,7 @@ import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -55,7 +56,10 @@ import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesBean;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesBoundPropertiesHolder;
|
||||
import org.springframework.boot.context.properties.ConstructorBinding;
|
||||
import org.springframework.boot.context.properties.source.ConfigurationProperty;
|
||||
import org.springframework.boot.context.properties.source.ConfigurationPropertyName;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.core.KotlinDetector;
|
||||
@@ -77,6 +81,8 @@ import org.springframework.util.StringUtils;
|
||||
* @author Christian Dupuis
|
||||
* @author Dave Syer
|
||||
* @author Stephane Nicoll
|
||||
* @author Madhura Bhave
|
||||
* @author Andy Wilkinson
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Endpoint(id = "configprops")
|
||||
@@ -120,8 +126,10 @@ public class ConfigurationPropertiesReportEndpoint implements ApplicationContext
|
||||
Map<String, ConfigurationPropertiesBeanDescriptor> descriptors = new HashMap<>();
|
||||
beans.forEach((beanName, bean) -> {
|
||||
String prefix = bean.getAnnotation().prefix();
|
||||
descriptors.put(beanName, new ConfigurationPropertiesBeanDescriptor(prefix,
|
||||
sanitize(prefix, safeSerialize(mapper, bean.getInstance(), prefix))));
|
||||
descriptors.put(beanName,
|
||||
new ConfigurationPropertiesBeanDescriptor(prefix,
|
||||
sanitize(prefix, safeSerialize(mapper, bean.getInstance(), prefix)),
|
||||
getInputs(prefix, safeSerialize(mapper, bean.getInstance(), prefix))));
|
||||
});
|
||||
return new ContextConfigurationProperties(descriptors,
|
||||
(context.getParent() != null) ? context.getParent().getId() : null);
|
||||
@@ -229,6 +237,67 @@ public class ConfigurationPropertiesReportEndpoint implements ApplicationContext
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> getInputs(String prefix, Map<String, Object> map) {
|
||||
map.forEach((key, value) -> {
|
||||
String qualifiedKey = (prefix.isEmpty() ? prefix : prefix + ".") + key;
|
||||
if (value instanceof Map) {
|
||||
map.put(key, getInputs(qualifiedKey, (Map<String, Object>) value));
|
||||
}
|
||||
else if (value instanceof List) {
|
||||
map.put(key, getInputs(qualifiedKey, (List<Object>) value));
|
||||
}
|
||||
else {
|
||||
map.put(key, applyInput(qualifiedKey));
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Object> getInputs(String prefix, List<Object> list) {
|
||||
List<Object> augmented = new ArrayList<>();
|
||||
int index = 0;
|
||||
for (Object item : list) {
|
||||
String name = prefix + "[" + index++ + "]";
|
||||
if (item instanceof Map) {
|
||||
augmented.add(getInputs(name, (Map<String, Object>) item));
|
||||
}
|
||||
else if (item instanceof List) {
|
||||
augmented.add(getInputs(name, (List<Object>) item));
|
||||
}
|
||||
else {
|
||||
augmented.add(applyInput(name));
|
||||
}
|
||||
}
|
||||
return augmented;
|
||||
}
|
||||
|
||||
private Map<String, Object> applyInput(String qualifiedKey) {
|
||||
if (!this.context.containsBean(ConfigurationPropertiesBoundPropertiesHolder.BEAN_NAME)) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
ConfigurationPropertiesBoundPropertiesHolder bean = this.context.getBean(
|
||||
ConfigurationPropertiesBoundPropertiesHolder.BEAN_NAME,
|
||||
ConfigurationPropertiesBoundPropertiesHolder.class);
|
||||
Map<ConfigurationPropertyName, ConfigurationProperty> boundProperties = bean.getProperties();
|
||||
ConfigurationPropertyName currentName = ConfigurationPropertyName.adapt(qualifiedKey, '.');
|
||||
ConfigurationProperty candidate = boundProperties.get(currentName);
|
||||
if (candidate == null && currentName.isLastElementIndexed()) {
|
||||
candidate = boundProperties.get(currentName.chop(currentName.getNumberOfElements() - 1));
|
||||
}
|
||||
return (candidate != null) ? getInput(currentName.toString(), candidate) : Collections.emptyMap();
|
||||
}
|
||||
|
||||
private Map<String, Object> getInput(String property, ConfigurationProperty candidate) {
|
||||
Map<String, Object> input = new LinkedHashMap<>();
|
||||
String origin = (candidate.getOrigin() != null) ? candidate.getOrigin().toString() : "none";
|
||||
Object value = candidate.getValue();
|
||||
input.put("origin", origin);
|
||||
input.put("value", this.sanitizer.sanitize(property, value));
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension to {@link JacksonAnnotationIntrospector} to suppress CGLIB generated bean
|
||||
* properties.
|
||||
@@ -457,9 +526,13 @@ public class ConfigurationPropertiesReportEndpoint implements ApplicationContext
|
||||
|
||||
private final Map<String, Object> properties;
|
||||
|
||||
private ConfigurationPropertiesBeanDescriptor(String prefix, Map<String, Object> properties) {
|
||||
private final Map<String, Object> inputs;
|
||||
|
||||
private ConfigurationPropertiesBeanDescriptor(String prefix, Map<String, Object> properties,
|
||||
Map<String, Object> inputs) {
|
||||
this.prefix = prefix;
|
||||
this.properties = properties;
|
||||
this.inputs = inputs;
|
||||
}
|
||||
|
||||
public String getPrefix() {
|
||||
@@ -470,6 +543,10 @@ public class ConfigurationPropertiesReportEndpoint implements ApplicationContext
|
||||
return this.properties;
|
||||
}
|
||||
|
||||
public Map<String, Object> getInputs() {
|
||||
return this.inputs;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -60,20 +60,26 @@ class ConfigurationPropertiesReportEndpointTests {
|
||||
@Test
|
||||
void descriptorWithJavaBeanBindMethodDetectsRelevantProperties() {
|
||||
this.contextRunner.withUserConfiguration(TestPropertiesConfiguration.class).run(assertProperties("test",
|
||||
(properties) -> assertThat(properties).containsOnlyKeys("dbPassword", "myTestProperty", "duration")));
|
||||
(properties) -> assertThat(properties).containsOnlyKeys("dbPassword", "myTestProperty", "duration"),
|
||||
(inputs) -> {
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void descriptorWithValueObjectBindMethodDetectsRelevantProperties() {
|
||||
this.contextRunner.withUserConfiguration(ImmutablePropertiesConfiguration.class).run(assertProperties(
|
||||
"immutable",
|
||||
(properties) -> assertThat(properties).containsOnlyKeys("dbPassword", "myTestProperty", "duration")));
|
||||
(properties) -> assertThat(properties).containsOnlyKeys("dbPassword", "myTestProperty", "duration"),
|
||||
(inputs) -> {
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void descriptorWithValueObjectBindMethodUseDedicatedConstructor() {
|
||||
this.contextRunner.withUserConfiguration(MultiConstructorPropertiesConfiguration.class).run(assertProperties(
|
||||
"multiconstructor", (properties) -> assertThat(properties).containsOnly(entry("name", "test"))));
|
||||
this.contextRunner.withUserConfiguration(MultiConstructorPropertiesConfiguration.class)
|
||||
.run(assertProperties("multiconstructor",
|
||||
(properties) -> assertThat(properties).containsOnly(entry("name", "test")), (inputs) -> {
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -84,49 +90,89 @@ class ConfigurationPropertiesReportEndpointTests {
|
||||
assertThat(properties).containsOnlyKeys("name", "nested");
|
||||
Map<String, Object> nested = (Map<String, Object>) properties.get("nested");
|
||||
assertThat(nested).containsOnly(entry("name", "nested"), entry("counter", 42));
|
||||
}, (inputs) -> {
|
||||
Map<String, Object> nested = (Map<String, Object>) inputs.get("nested");
|
||||
Map<String, Object> name = (Map<String, Object>) nested.get("name");
|
||||
Map<String, Object> counter = (Map<String, Object>) nested.get("counter");
|
||||
assertThat(name.get("value")).isEqualTo("nested");
|
||||
assertThat(name.get("origin"))
|
||||
.isEqualTo("\"immutablenested.nested.name\" from property source \"test\"");
|
||||
assertThat(counter.get("origin"))
|
||||
.isEqualTo("\"immutablenested.nested.counter\" from property source \"test\"");
|
||||
assertThat(counter.get("value")).isEqualTo("42");
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void descriptorWithSimpleList() {
|
||||
this.contextRunner.withUserConfiguration(SensiblePropertiesConfiguration.class)
|
||||
.withPropertyValues("sensible.simpleList=a,b").run(assertProperties("sensible", (properties) -> {
|
||||
assertThat(properties.get("simpleList")).isInstanceOf(List.class);
|
||||
List<String> list = (List<String>) properties.get("simpleList");
|
||||
assertThat(list).hasSize(2);
|
||||
assertThat(list.get(0)).isEqualTo("a");
|
||||
assertThat(list.get(1)).isEqualTo("b");
|
||||
}, (inputs) -> {
|
||||
List<Object> list = (List<Object>) inputs.get("simpleList");
|
||||
assertThat(list).hasSize(2);
|
||||
Map<String, String> item = (Map<String, String>) list.get(0);
|
||||
String origin = item.get("origin");
|
||||
String value = item.get("value");
|
||||
assertThat(value).isEqualTo("a,b");
|
||||
assertThat(origin).isEqualTo("\"sensible.simpleList\" from property source \"test\"");
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void descriptorDoesNotIncludePropertyWithNullValue() {
|
||||
this.contextRunner.withUserConfiguration(TestPropertiesConfiguration.class)
|
||||
.run(assertProperties("test", (properties) -> assertThat(properties).doesNotContainKey("nullValue")));
|
||||
this.contextRunner.withUserConfiguration(TestPropertiesConfiguration.class).run(assertProperties("test",
|
||||
(properties) -> assertThat(properties).doesNotContainKey("nullValue"), (inputs) -> {
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void descriptorWithDurationProperty() {
|
||||
this.contextRunner.withUserConfiguration(TestPropertiesConfiguration.class).run(assertProperties("test",
|
||||
(properties) -> assertThat(properties.get("duration")).isEqualTo(Duration.ofSeconds(10).toString())));
|
||||
(properties) -> assertThat(properties.get("duration")).isEqualTo(Duration.ofSeconds(10).toString()),
|
||||
(inputs) -> {
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void descriptorWithNonCamelCaseProperty() {
|
||||
this.contextRunner.withUserConfiguration(MixedCasePropertiesConfiguration.class).run(assertProperties(
|
||||
"mixedcase", (properties) -> assertThat(properties.get("myURL")).isEqualTo("https://example.com")));
|
||||
this.contextRunner.withUserConfiguration(MixedCasePropertiesConfiguration.class)
|
||||
.run(assertProperties("mixedcase",
|
||||
(properties) -> assertThat(properties.get("myURL")).isEqualTo("https://example.com"),
|
||||
(inputs) -> {
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void descriptorWithMixedCaseProperty() {
|
||||
this.contextRunner.withUserConfiguration(MixedCasePropertiesConfiguration.class).run(assertProperties(
|
||||
"mixedcase", (properties) -> assertThat(properties.get("mIxedCase")).isEqualTo("mixed")));
|
||||
"mixedcase", (properties) -> assertThat(properties.get("mIxedCase")).isEqualTo("mixed"), (inputs) -> {
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void descriptorWithSingleLetterProperty() {
|
||||
this.contextRunner.withUserConfiguration(MixedCasePropertiesConfiguration.class)
|
||||
.run(assertProperties("mixedcase", (properties) -> assertThat(properties.get("z")).isEqualTo("zzz")));
|
||||
this.contextRunner.withUserConfiguration(MixedCasePropertiesConfiguration.class).run(assertProperties(
|
||||
"mixedcase", (properties) -> assertThat(properties.get("z")).isEqualTo("zzz"), (inputs) -> {
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void descriptorWithSimpleBooleanProperty() {
|
||||
this.contextRunner.withUserConfiguration(BooleanPropertiesConfiguration.class).run(assertProperties("boolean",
|
||||
(properties) -> assertThat(properties.get("simpleBoolean")).isEqualTo(true)));
|
||||
(properties) -> assertThat(properties.get("simpleBoolean")).isEqualTo(true), (inputs) -> {
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void descriptorWithMixedBooleanProperty() {
|
||||
this.contextRunner.withUserConfiguration(BooleanPropertiesConfiguration.class).run(assertProperties("boolean",
|
||||
(properties) -> assertThat(properties.get("mixedBoolean")).isEqualTo(true)));
|
||||
(properties) -> assertThat(properties.get("mixedBoolean")).isEqualTo(true), (inputs) -> {
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -135,6 +181,7 @@ class ConfigurationPropertiesReportEndpointTests {
|
||||
.run(assertProperties("test", (properties) -> {
|
||||
assertThat(properties.get("dbPassword")).isEqualTo("******");
|
||||
assertThat(properties.get("myTestProperty")).isEqualTo("654321");
|
||||
}, (inputs) -> {
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -144,6 +191,7 @@ class ConfigurationPropertiesReportEndpointTests {
|
||||
.withPropertyValues("test.keys-to-sanitize=property").run(assertProperties("test", (properties) -> {
|
||||
assertThat(properties.get("dbPassword")).isEqualTo("123456");
|
||||
assertThat(properties.get("myTestProperty")).isEqualTo("******");
|
||||
}, (inputs) -> {
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -153,6 +201,7 @@ class ConfigurationPropertiesReportEndpointTests {
|
||||
.withPropertyValues("test.keys-to-sanitize=.*pass.*").run(assertProperties("test", (properties) -> {
|
||||
assertThat(properties.get("dbPassword")).isEqualTo("******");
|
||||
assertThat(properties.get("myTestProperty")).isEqualTo("654321");
|
||||
}, (inputs) -> {
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -166,38 +215,61 @@ class ConfigurationPropertiesReportEndpointTests {
|
||||
assertThat(secrets.get("mine")).isEqualTo("******");
|
||||
assertThat(secrets.get("yours")).isEqualTo("******");
|
||||
assertThat(hidden.get("mine")).isEqualTo("******");
|
||||
}, (inputs) -> {
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizedUriWithSensitiveInfo() {
|
||||
this.contextRunner.withUserConfiguration(SensiblePropertiesConfiguration.class)
|
||||
.withPropertyValues("sensible.sensitiveUri=http://user:password@localhost:8080")
|
||||
.run(assertProperties("sensible", (properties) -> assertThat(properties.get("sensitiveUri"))
|
||||
.isEqualTo("http://user:******@localhost:8080")));
|
||||
.isEqualTo("http://user:******@localhost:8080"), (inputs) -> {
|
||||
Map<String, Object> sensitiveUri = (Map<String, Object>) inputs.get("sensitiveUri");
|
||||
assertThat(sensitiveUri.get("value")).isEqualTo("http://user:******@localhost:8080");
|
||||
assertThat(sensitiveUri.get("origin"))
|
||||
.isEqualTo("\"sensible.sensitiveUri\" from property source \"test\"");
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizedUriWithNoPassword() {
|
||||
this.contextRunner.withUserConfiguration(SensiblePropertiesConfiguration.class)
|
||||
.withPropertyValues("sensible.noPasswordUri=http://user:@localhost:8080")
|
||||
.run(assertProperties("sensible", (properties) -> assertThat(properties.get("noPasswordUri"))
|
||||
.isEqualTo("http://user:******@localhost:8080")));
|
||||
.isEqualTo("http://user:******@localhost:8080"), (inputs) -> {
|
||||
Map<String, Object> noPasswordUri = (Map<String, Object>) inputs.get("noPasswordUri");
|
||||
assertThat(noPasswordUri.get("value")).isEqualTo("http://user:******@localhost:8080");
|
||||
assertThat(noPasswordUri.get("origin"))
|
||||
.isEqualTo("\"sensible.noPasswordUri\" from property source \"test\"");
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeLists() {
|
||||
this.contextRunner.withUserConfiguration(SensiblePropertiesConfiguration.class)
|
||||
.withPropertyValues("sensible.listItems[0].some-password=password")
|
||||
.run(assertProperties("sensible", (properties) -> {
|
||||
assertThat(properties.get("listItems")).isInstanceOf(List.class);
|
||||
List<Object> list = (List<Object>) properties.get("listItems");
|
||||
assertThat(list).hasSize(1);
|
||||
Map<String, Object> item = (Map<String, Object>) list.get(0);
|
||||
assertThat(item.get("somePassword")).isEqualTo("******");
|
||||
}, (inputs) -> {
|
||||
List<Object> list = (List<Object>) inputs.get("listItems");
|
||||
assertThat(list).hasSize(1);
|
||||
Map<String, Object> item = (Map<String, Object>) list.get(0);
|
||||
Map<String, Object> somePassword = (Map<String, Object>) item.get("somePassword");
|
||||
assertThat(somePassword.get("value")).isEqualTo("******");
|
||||
assertThat(somePassword.get("origin"))
|
||||
.isEqualTo("\"sensible.listItems[0].some-password\" from property source \"test\"");
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsOfListsAreSanitized() {
|
||||
this.contextRunner.withUserConfiguration(SensiblePropertiesConfiguration.class)
|
||||
.withPropertyValues("sensible.listOfListItems[0][0].some-password=password")
|
||||
.run(assertProperties("sensible", (properties) -> {
|
||||
assertThat(properties.get("listOfListItems")).isInstanceOf(List.class);
|
||||
List<List<Object>> listOfLists = (List<List<Object>>) properties.get("listOfListItems");
|
||||
@@ -206,11 +278,22 @@ class ConfigurationPropertiesReportEndpointTests {
|
||||
assertThat(list).hasSize(1);
|
||||
Map<String, Object> item = (Map<String, Object>) list.get(0);
|
||||
assertThat(item.get("somePassword")).isEqualTo("******");
|
||||
}, (inputs) -> {
|
||||
assertThat(inputs.get("listOfListItems")).isInstanceOf(List.class);
|
||||
List<List<Object>> listOfLists = (List<List<Object>>) inputs.get("listOfListItems");
|
||||
assertThat(listOfLists).hasSize(1);
|
||||
List<Object> list = listOfLists.get(0);
|
||||
assertThat(list).hasSize(1);
|
||||
Map<String, Object> item = (Map<String, Object>) list.get(0);
|
||||
Map<String, Object> somePassword = (Map<String, Object>) item.get("somePassword");
|
||||
assertThat(somePassword.get("value")).isEqualTo("******");
|
||||
assertThat(somePassword.get("origin")).isEqualTo(
|
||||
"\"sensible.listOfListItems[0][0].some-password\" from property source \"test\"");
|
||||
}));
|
||||
}
|
||||
|
||||
private ContextConsumer<AssertableApplicationContext> assertProperties(String prefix,
|
||||
Consumer<Map<String, Object>> properties) {
|
||||
Consumer<Map<String, Object>> properties, Consumer<Map<String, Object>> inputs) {
|
||||
return (context) -> {
|
||||
ConfigurationPropertiesReportEndpoint endpoint = context
|
||||
.getBean(ConfigurationPropertiesReportEndpoint.class);
|
||||
@@ -222,6 +305,7 @@ class ConfigurationPropertiesReportEndpointTests {
|
||||
ConfigurationPropertiesBeanDescriptor descriptor = allProperties.getBeans().get(key.get());
|
||||
assertThat(descriptor.getPrefix()).isEqualTo(prefix);
|
||||
properties.accept(descriptor.getProperties());
|
||||
inputs.accept(descriptor.getInputs());
|
||||
};
|
||||
}
|
||||
|
||||
@@ -574,6 +658,8 @@ class ConfigurationPropertiesReportEndpointTests {
|
||||
|
||||
private URI noPasswordUri = URI.create("http://user:@localhost:8080");
|
||||
|
||||
private List<String> simpleList = new ArrayList<>();
|
||||
|
||||
private List<ListItem> listItems = new ArrayList<>();
|
||||
|
||||
private List<List<ListItem>> listOfListItems = new ArrayList<>();
|
||||
@@ -615,6 +701,10 @@ class ConfigurationPropertiesReportEndpointTests {
|
||||
this.listOfListItems = listOfListItems;
|
||||
}
|
||||
|
||||
public List<String> getSimpleList() {
|
||||
return this.simpleList;
|
||||
}
|
||||
|
||||
public static class ListItem {
|
||||
|
||||
private String somePassword = "secret";
|
||||
|
||||
Reference in New Issue
Block a user