LegacyPropertyTranslator.java
/*
* Copyright (c) 2007-2026 MetaSolutions AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.entrystore.rest.springboot.configuration;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.logging.Log;
import org.entrystore.repository.config.Settings;
import org.entrystore.rest.springboot.security.SamlAcsRequestMatcher;
import org.springframework.boot.EnvironmentPostProcessor;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.ConfigurationPropertyName;
import org.springframework.boot.context.properties.source.ConfigurationPropertySource;
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
import org.springframework.boot.context.properties.source.ConfigurationPropertyState;
import org.springframework.boot.logging.DeferredLogFactory;
import org.springframework.core.Ordered;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.EnumerablePropertySource;
import org.springframework.core.env.MapPropertySource;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Predicate;
import java.util.function.UnaryOperator;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.IntStream;
import java.util.stream.StreamSupport;
/**
* Translates EntryStore 5.x SAML, CAS and HTTP Basic settings that 6.x no longer reads into their 6.x keys, so a
* 5.x configuration keeps working unchanged. Values are translated as 5.x read them, and each translation is
* logged at WARN with the 6.x key to set instead. The translations go into one property source of the lowest
* precedence, so a 6.x key that is set explicitly, in any spelling, always wins.
*
* <p>SAML registrations are emitted only while SAML is enabled (Spring Boot would otherwise build its own
* repository for them), and only for IdP ids without 6.x registration keys.
*
* <p>Adding a future rename that keeps its value: append it to {@link #RENAMED_KEYS}.
*/
public final class LegacyPropertyTranslator implements EnvironmentPostProcessor, Ordered {
static final String PROPERTY_SOURCE_NAME = "entrystore-legacy-translations";
// Legacy key -> 6.x key, the value carried over unchanged.
private static final Map<String, String> RENAMED_KEYS = Map.of(
Settings.AUTH_CAS_SERVER_LOGIN_URL, "entrystore.auth.cas.server.url-login");
// Legacy enable key -> the (lowercase) values with which 5.x enabled the feature; its 6.x key adds ".enabled".
private static final Map<String, Set<String>> ENABLE_KEYS = Map.of(
"entrystore.auth.saml", Set.of("on", "new"),
"entrystore.auth.cas", Set.of("on"),
"entrystore.auth.http-basic", Set.of("on", "true"));
// 5.x per-IdP key suffix (entrystore.auth.saml.idp.<id>.<suffix>) -> the 5.x single-IdP key for the same setting.
private static final Map<String, String> SINGLE_IDP_KEYS = Map.of(
"metadata.url", Settings.AUTH_SAML_LEGACY_IDP_METADATA_URL,
"relying-party-id", Settings.AUTH_SAML_LEGACY_RELYING_PARTY_ID,
"redirect-method", Settings.AUTH_SAML_LEGACY_REDIRECT_METHOD,
"metadata.max-age", Settings.AUTH_SAML_LEGACY_IDP_METADATA_MAXAGE,
"user-auto-provisioning", Settings.AUTH_SAML_LEGACY_USER_AUTO_PROVISIONING);
// 5.x per-IdP key suffix -> the key under spring.security.saml2.relyingparty.registration.<id>, value unchanged.
private static final Map<String, String> REGISTRATION_KEYS = Map.of(
"metadata.url", "assertingparty.metadata-uri",
"relying-party-id", "entity-id");
private static final String IDP_KEY_TEMPLATE = "entrystore.auth.saml.idp.%s.%s";
private static final String REGISTRATION = "spring.security.saml2.relyingparty.registration.%s";
private static final Pattern IDP_KEY = Pattern.compile("entrystore\\.auth\\.saml\\.idp\\.([^.\\[\\]]+)\\.(.+)");
private final Log log;
public LegacyPropertyTranslator(DeferredLogFactory logFactory) {
this.log = logFactory.getLog(LegacyPropertyTranslator.class);
}
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
var translated = new LinkedHashMap<String, Object>();
var translation = new Translation(environment, log, translated);
ENABLE_KEYS.forEach((key, enablingValues) -> translation.transform(key, key + ".enabled",
value -> Boolean.toString(enablingValues.contains(value.toLowerCase(Locale.ROOT)))));
RENAMED_KEYS.forEach(translation::rename);
translation.saml();
translation.list(Settings.AUTH_SAML_REDIRECT_DOMAIN_WHITELIST);
translation.idpIds(suffix -> suffix.startsWith("domains."))
.forEach(id -> translation.list(IDP_KEY_TEMPLATE.formatted(id, "domains")));
if (!translated.isEmpty()) {
environment.getPropertySources().addLast(new MapPropertySource(PROPERTY_SOURCE_NAME, translated));
}
}
// Must run after ConfigDataEnvironmentPostProcessor so entrystore.properties (imported via
// spring.config.import) is part of the Environment.
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE - 2;
}
private record Translation(ConfigurableEnvironment environment, Log log, Map<String, Object> translated) {
private void saml() {
boolean enabled = Binder.get(environment).bind(Settings.AUTH_SAML_ENABLED, Boolean.class)
.orElseGet(() -> "true".equals(translated.get(Settings.AUTH_SAML_ENABLED)));
if (!enabled) {
return;
}
String selector = StringUtils.lowerCase(legacy("entrystore.auth.saml"), Locale.ROOT);
boolean singleIdpKeys = legacy(SINGLE_IDP_KEYS.get("relying-party-id")) != null
|| legacy(SINGLE_IDP_KEYS.get("metadata.url")) != null;
if ("on".equals(selector) || (!"new".equals(selector) && singleIdpKeys)) {
singleIdp();
} else {
multiIdp();
}
}
private void singleIdp() {
String id = Objects.requireNonNullElse(legacy(Settings.AUTH_SAML_DEFAULT_IDP), "default");
if (!registration(id, SINGLE_IDP_KEYS::get, UnaryOperator.identity())) {
return;
}
rename(SINGLE_IDP_KEYS.get("metadata.max-age"), IDP_KEY_TEMPLATE.formatted(id, "metadata.max-age"));
String provisioningKey = IDP_KEY_TEMPLATE.formatted(id, "user-auto-provisioning");
transform(SINGLE_IDP_KEYS.get("user-auto-provisioning"), provisioningKey,
value -> Boolean.toString("on".equalsIgnoreCase(value)));
// Always set: it also makes the IdP known to the domain routing, which then sends every login to it.
imply(provisioningKey, "false");
imply(Settings.AUTH_SAML_DEFAULT_IDP, id);
}
private void multiIdp() {
String bareIdps = legacy(Settings.AUTH_SAML_IDPS);
Collection<String> ids = bareIdps != null ? List.of(bareIdps) : numberedValues(Settings.AUTH_SAML_IDPS);
if (ids.isEmpty()) {
ids = idpIds(suffix -> suffix.equals("metadata.url"));
}
for (String id : ids) {
registration(id, suffix -> IDP_KEY_TEMPLATE.formatted(id, suffix), acsUrl -> acsUrl
+ (acsUrl.contains("?") ? "&" : "?") + SamlAcsRequestMatcher.LEGACY_IDP_PARAMETER + "=" + id);
}
}
/**
* Emits the relying-party registration 5.x built for one IdP, reading the 5.x key of each per-IdP setting
* through {@code legacyKey} (a per-IdP suffix to its key); returns whether it did.
*/
private boolean registration(String id, UnaryOperator<String> legacyKey, UnaryOperator<String> acsLocation) {
String metadataUrlKey = legacyKey.apply("metadata.url");
if (legacy(metadataUrlKey) == null) {
log.warn("5.x SAML IdP '" + id + "' is not translated: " + metadataUrlKey + " is not set.");
return false;
}
String registration = REGISTRATION.formatted(id);
var registrationName = ConfigurationPropertyName.adapt(registration, '.');
if (anySource(source -> source.containsDescendantOf(registrationName) == ConfigurationPropertyState.PRESENT)) {
log.warn("5.x SAML settings for IdP '" + id + "' are ignored because " + registration + ".* is set.");
return false;
}
REGISTRATION_KEYS.forEach((suffix, key) -> rename(legacyKey.apply(suffix), registration + "." + key));
transform(Settings.AUTH_SAML_LEGACY_ASSERTION_CONSUMER_SERVICE_URL, registration + ".acs.location",
acsLocation);
String bindingKey = registration + ".assertingparty.singlesignon.binding";
// 5.x sent the authentication request with GET unless redirect-method was post.
transform(legacyKey.apply("redirect-method"), bindingKey,
value -> "post".equalsIgnoreCase(value) ? "post" : "redirect");
imply(bindingKey, "redirect");
// 5.x always asked for this format; without one, an IdP may return a NameID that names another user.
imply(registration + ".name-id-format", "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified");
// 5.x never signed authentication requests, and some IdPs' metadata asks for signed ones.
imply(registration + ".assertingparty.singlesignon.sign-request", "false");
return true;
}
/**
* Translates a list in the 5.x numbering ({@code key.1}, {@code key.2}, … up to the first gap), which 6.x
* binds as an empty list, into {@code key[0]}, {@code key[1]}, …. A bare value or an entry at index 0 is
* left to Spring, which binds it. A bare value, even an empty one, overrides numbered entries, as in 5.x,
* and a WARN says so.
*/
private void list(String key) {
if (isSet(key)) {
if (environment.containsProperty(key + ".1")) {
log.warn("EntryStore property '" + key + ".1', '" + key + ".2', ... is ignored because '" + key
+ "' is set.");
}
return;
}
if (isSet(key + "[0]")) {
return;
}
List<String> values = numberedValues(key);
if (values.isEmpty()) {
return;
}
for (int i = 0; i < values.size(); i++) {
translated.put(key + "[" + i + "]", values.get(i));
}
log.warn("EntryStore property '" + key + ".1', '" + key + ".2', ... uses the 5.x numbering and was applied "
+ "as '" + key + "=" + String.join(",", values) + "'; set that key instead.");
}
/** The ids of {@code entrystore.auth.saml.idp.<id>.<suffix>} keys whose suffix matches. */
private Set<String> idpIds(Predicate<String> suffix) {
Set<String> ids = new TreeSet<>();
for (var source : environment.getPropertySources()) {
if (source instanceof EnumerablePropertySource<?> enumerable) {
for (String name : enumerable.getPropertyNames()) {
Matcher matcher = IDP_KEY.matcher(name);
if (matcher.matches() && suffix.test(matcher.group(2))) {
ids.add(matcher.group(1));
}
}
}
}
return ids;
}
private List<String> numberedValues(String key) {
return IntStream.iterate(1, i -> i + 1)
.mapToObj(i -> environment.getProperty(key + "." + i))
.takeWhile(Objects::nonNull)
.toList();
}
private void rename(String legacyKey, String newKey) {
transform(legacyKey, newKey, UnaryOperator.identity());
}
/** Maps a set {@code legacyKey} to {@code newKey}, unless the transformation yields null or newKey is set. */
private void transform(String legacyKey, String newKey, UnaryOperator<String> transformation) {
String legacyValue = legacy(legacyKey);
String value = legacyValue == null ? null : transformation.apply(legacyValue);
if (value == null) {
return;
}
if (isSet(newKey)) {
log.warn("EntryStore property '" + legacyKey + "' is deprecated and ignored because '" + newKey
+ "' is set.");
return;
}
translated.put(newKey, value);
log.warn("EntryStore property '" + legacyKey + "' is deprecated since 6.0 and was applied as '" + newKey
+ "=" + value + "'; set that key instead.");
}
/** Sets a key that 5.x behaviour implies, unless it is set explicitly or by a translation. */
private void imply(String key, String value) {
if (!isSet(key)) {
translated.putIfAbsent(key, value);
}
}
private String legacy(String key) {
return StringUtils.stripToNull(environment.getProperty(key));
}
private boolean isSet(String key) {
var name = ConfigurationPropertyName.adapt(key, '.');
return anySource(source -> source.getConfigurationProperty(name) != null);
}
private boolean anySource(Predicate<ConfigurationPropertySource> test) {
return StreamSupport.stream(ConfigurationPropertySources.get(environment).spliterator(), false).anyMatch(test);
}
}
}