diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cc0f1b33..e66f540dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,3 +18,4 @@ - [feat: support audit log](https://github.com/Tencent/spring-cloud-tencent/pull/1812) - [feat: support config effective value ](https://github.com/Tencent/spring-cloud-tencent/pull/1815) - [feat: support encoding and decoding TSF headers without TSF Consul](https://github.com/Tencent/spring-cloud-tencent/pull/1816) +- [feat: protect encrypted config values and improve config cache fallback](https://github.com/Tencent/spring-cloud-tencent/pull/1818) diff --git a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/ConfigurationModifier.java b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/ConfigurationModifier.java index 8a7f5f234..fcdaa6b44 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/ConfigurationModifier.java +++ b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/ConfigurationModifier.java @@ -17,6 +17,7 @@ package com.tencent.cloud.polaris.config; +import java.io.File; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -106,9 +107,10 @@ public class ConfigurationModifier implements PolarisConfigurationConfigModifier ConnectorConfigImpl connectorConfig = configuration.getConfigFile().getServerConnector(); // set connector type connectorConfig.setConnectorType(polarisConfigProperties.getDataSource()); + String localFileRootPath = polarisConfigProperties.getLocalFileRootPath(); + // The same directory stores local-source files and remote-source fallback caches. + connectorConfig.setPersistDir(localFileRootPath); if (StringUtils.equalsIgnoreCase(polarisConfigProperties.getDataSource(), LOCAL_FILE_CONNECTOR_TYPE)) { - String localFileRootPath = polarisConfigProperties.getLocalFileRootPath(); - connectorConfig.setPersistDir(localFileRootPath); LOGGER.info("[SCT] Run spring cloud tencent config with local data source. localFileRootPath = {}", localFileRootPath); return; } @@ -131,7 +133,7 @@ public class ConfigurationModifier implements PolarisConfigurationConfigModifier // enable close check address for unit tests if (polarisConfigProperties.isCheckAddress()) { - checkAddressAccessible(configAddresses); + checkAddressAccessible(configAddresses, connectorConfig.getFallbackToLocalCache()); } connectorConfig.setAddresses(configAddresses); @@ -218,7 +220,7 @@ public class ConfigurationModifier implements PolarisConfigurationConfigModifier return polarisAddresses; } - private void checkAddressAccessible(List configAddresses) { + private void checkAddressAccessible(List configAddresses, Boolean fallbackToLocalCache) { // check address can connect configAddresses.forEach(address -> { String[] ipPort; @@ -243,7 +245,13 @@ public class ConfigurationModifier implements PolarisConfigurationConfigModifier String errMsg = "Config server address (" + address + ") can not be connected. Please check your config in bootstrap.yml" + " with spring.cloud.polaris.address or spring.cloud.polaris.config.address."; if (polarisConfigProperties.isShutdownIfConnectToConfigServerFailed()) { - throw new IllegalArgumentException(errMsg); + if (Boolean.TRUE.equals(fallbackToLocalCache) && hasLocalConfigCache()) { + LOGGER.warn("{} Continue startup to fall back to cached config files in {}.", + errMsg, polarisConfigProperties.getLocalFileRootPath()); + } + else { + throw new IllegalArgumentException(errMsg); + } } else { LOGGER.error(errMsg); @@ -251,4 +259,33 @@ public class ConfigurationModifier implements PolarisConfigurationConfigModifier } }); } + + private boolean hasLocalConfigCache() { + String rootPath = polarisConfigProperties.getLocalFileRootPath(); + if (StringUtils.isBlank(rootPath)) { + return false; + } + File[] files = new File(rootPath).listFiles(this::isPolarisConfigCacheFile); + return files != null && files.length > 0; + } + + /** + * Polaris persist files use {@code encodedNamespace#encodedFileGroup#encodedFileName.yaml}. + * Empty files and names that only happen to contain {@code #} are ignored. + */ + private boolean isPolarisConfigCacheFile(File file) { + if (file == null || !file.isFile() || file.length() <= 0) { + return false; + } + String name = file.getName(); + if (!name.endsWith(".yaml")) { + return false; + } + String stem = name.substring(0, name.length() - ".yaml".length()); + String[] parts = stem.split("#", -1); + return parts.length == 3 + && StringUtils.isNotBlank(parts[0]) + && StringUtils.isNotBlank(parts[1]) + && StringUtils.isNotBlank(parts[2]); + } } diff --git a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/PolarisConfigPropertyAutoRefresher.java b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/PolarisConfigPropertyAutoRefresher.java index a829dbbcb..49594d3d1 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/PolarisConfigPropertyAutoRefresher.java +++ b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/PolarisConfigPropertyAutoRefresher.java @@ -17,6 +17,10 @@ package com.tencent.cloud.polaris.config.adapter; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; import java.time.LocalDateTime; import java.util.HashMap; import java.util.HashSet; @@ -66,6 +70,24 @@ public abstract class PolarisConfigPropertyAutoRefresher implements ApplicationL private static final Logger LOGGER = LoggerFactory.getLogger(PolarisConfigPropertyAutoRefresher.class); private static final Set registeredPolarisPropertySets = Sets.newConcurrentHashSet(); + /** + * Property keys contributed by encrypted config files. Values of these keys must never be + * written to logs in plain text. + *

+ * Grow-only on purpose: once a key is known to be sensitive, keep masking it even after the + * encrypted file drops it. Over-masking only costs troubleshooting convenience, while + * under-masking is a leak. + */ + private static final Set encryptedPropertyKeys = Sets.newConcurrentHashSet(); + private static final String FINGERPRINT_ALGORITHM = "SHA-256"; + /** + * Number of digest bytes kept in a fingerprint. 4 bytes are enough to tell two values apart. + */ + private static final int FINGERPRINT_BYTES = 4; + /** + * Random per JVM: see {@link #fingerprint(String)} for why the digest must be salted. + */ + private static final byte[] FINGERPRINT_SALT = newFingerprintSalt(); private final PolarisConfigProperties polarisConfigProperties; private final AtomicBoolean registered = new AtomicBoolean(false); // this class provides customized logic for some customers to configure special business group files @@ -163,6 +185,9 @@ public abstract class PolarisConfigPropertyAutoRefresher implements ApplicationL changedKeys.addAll(p.getSource().keySet()); this.registerPolarisConfigPublishChangeListener(p, polarisPropertySource); PolarisPropertySourceManager.addPropertySource(p); + // the loaded file carries the per-file encrypted flag, so register its keys + // right here rather than waiting for the first change event on that file + markEncryptedKeys(p.getConfigKVFile()); for (String changedKey : p.getSource().keySet()) { polarisPropertySource.getSource().put(changedKey, p.getSource().get(changedKey)); refreshSpringValue(changedKey); @@ -191,6 +216,11 @@ public abstract class PolarisConfigPropertyAutoRefresher implements ApplicationL LOGGER.info("[SCT Config] received polaris config change event and will refresh spring context." + " namespace = {}, group = {}, fileName = {}", listenPolarisPropertySource.getNamespace(), listenPolarisPropertySource.getGroup(), listenPolarisPropertySource.getFileName()); + // the change event is the only place carrying the plugin-level ConfigFile, + // which is where the server-side per-file encrypted flag can be read + markEncryptedKeys(listenPolarisPropertySource.getConfigKVFile(), + configKVFileChangeEvent.getConfigFile(), configKVFileChangeEvent.changedKeys()); + Map effectSource = effectPolarisPropertySource.getSource(); Map listenSource = listenPolarisPropertySource.getSource(); boolean isGroupRefresh = !listenPolarisPropertySource.equals(effectPolarisPropertySource); @@ -204,13 +234,23 @@ public abstract class PolarisConfigPropertyAutoRefresher implements ApplicationL for (String changedKey : configKVFileChangeEvent.changedKeys()) { ConfigPropertyChangeInfo configPropertyChangeInfo = configKVFileChangeEvent.getChangeInfo(changedKey); - LOGGER.info("[SCT Config] changed property = {}", configPropertyChangeInfo); + if (isEncryptedKey(changedKey)) { + LOGGER.info("[SCT Config] changed property = [key={}, changeType={}, oldValue={}, newValue={}]", + configPropertyChangeInfo.getPropertyName(), configPropertyChangeInfo.getChangeType(), + maskValue(configPropertyChangeInfo.getOldValue()), + maskValue(configPropertyChangeInfo.getNewValue())); + } + else { + LOGGER.info("[SCT Config] changed property = {}", configPropertyChangeInfo); + } // new ability to dynamically change log levels try { if (changedKey.startsWith("logging.level") && changedKey.length() >= 14) { String loggerName = changedKey.substring(14); String newValue = (String) configPropertyChangeInfo.getNewValue(); + // not masked even in an encrypted file: encryption is per file, and + // the value here is a log level, never a secret LOGGER.info("[SCT Config] set logging.level loggerName:{}, newValue:{}", loggerName, newValue); PolarisConfigLoggerContext.setLevel(loggerName, newValue); } @@ -267,6 +307,122 @@ public abstract class PolarisConfigPropertyAutoRefresher implements ApplicationL polarisConfigCustomExtensionLayer.executeRegisterPublishChangeListener(listenPolarisPropertySource, effectPolarisPropertySource); } + /** + * Registers the property keys of an encrypted config file, so that their values can be masked + * in logs afterwards. + *

+ * {@code configFile} must come from {@link ConfigKVFileChangeEvent#getConfigFile()}: that + * object belongs to the response chain, where {@code encrypted} is the per-file value pushed + * by the server. The request-side object is not usable as a criterion, because the crypto + * filter unconditionally sets it to true to declare crypto support. + * + * @param kvFile the config file whose property names will be registered + * @param configFile the plugin-level config file carrying the encrypted flag, may be null + * @param changedKeys keys from the change event; an ADDED key may not be in + * {@code kvFile.getPropertyNames()} yet + */ + private void markEncryptedKeys(ConfigKVFile kvFile, ConfigFile configFile, Set changedKeys) { + if (configFile == null || !configFile.isEncrypted()) { + return; + } + if (kvFile != null) { + Set propertyNames = kvFile.getPropertyNames(); + if (!CollectionUtils.isEmpty(propertyNames)) { + encryptedPropertyKeys.addAll(propertyNames); + } + } + if (!CollectionUtils.isEmpty(changedKeys)) { + encryptedPropertyKeys.addAll(changedKeys); + } + } + + /** + * Registers the property keys of a config file that reports itself as encrypted. + *

+ * Used where no change event is available, e.g. a file newly added to a watched group. + * {@code ConfigKVFile#isEncrypted()} resolves to the server-pushed per-file flag, so it is + * usable from the very first load. + * + * @param kvFile the loaded config file, may be null + */ + private void markEncryptedKeys(ConfigKVFile kvFile) { + if (kvFile == null || !kvFile.isEncrypted()) { + return; + } + Set propertyNames = kvFile.getPropertyNames(); + if (!CollectionUtils.isEmpty(propertyNames)) { + encryptedPropertyKeys.addAll(propertyNames); + } + } + + /** + * @param key the property key + * @return whether the value of the given key comes from an encrypted config file + */ + protected boolean isEncryptedKey(String key) { + return encryptedPropertyKeys.contains(key); + } + + /** + * Masks a property value of an encrypted config file. The length and a fingerprint are kept as + * hints for troubleshooting, the content is not exposed. + *

+ * Takes an Object rather than a String: both {@code ConfigPropertyChangeInfo#getOldValue()} + * and the resolved {@code @Value} result are declared as Object. + * + * @param value the raw value + * @return the masked value + */ + protected static String maskValue(Object value) { + if (value == null) { + return null; + } + String text = String.valueOf(value); + if (text.isEmpty()) { + return ""; + } + return "***(len=" + text.length() + ", fp=" + fingerprint(text) + ")"; + } + + /** + * Salted and truncated digest of a value, so that two masked values can be told apart even when + * their lengths are equal (e.g. an old and a new password of the same length). + *

+ * The salt is random per JVM on purpose. An unsalted digest of a single config value would be + * reversible by dictionary attack, since config values carry little entropy - that would defeat + * the masking. With a per-process salt the fingerprint stays comparable within one log file, + * which is what change diagnosis needs, and carries no information outside it. + *

+ * Truncated to 4 bytes: a collision only makes two different values look alike, it never + * exposes a value. + * + * @param text the raw value + * @return an 8-char hex fingerprint + */ + private static byte[] newFingerprintSalt() { + byte[] salt = new byte[16]; + new SecureRandom().nextBytes(salt); + return salt; + } + + private static String fingerprint(String text) { + try { + MessageDigest digest = MessageDigest.getInstance(FINGERPRINT_ALGORITHM); + digest.update(FINGERPRINT_SALT); + byte[] hash = digest.digest(text.getBytes(StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(FINGERPRINT_BYTES * 2); + for (int i = 0; i < FINGERPRINT_BYTES; i++) { + builder.append(Character.forDigit((hash[i] >> 4) & 0xF, 16)); + builder.append(Character.forDigit(hash[i] & 0xF, 16)); + } + return builder.toString(); + } + catch (NoSuchAlgorithmException e) { + // SHA-256 is mandated by the JDK spec, so this is unreachable in practice + return "unavailable"; + } + } + private Map calculateUnregister(List oldConfigFileMetadataList, List newConfigFileMetadataList) { @@ -334,4 +490,12 @@ public abstract class PolarisConfigPropertyAutoRefresher implements ApplicationL public void setRegistered(boolean registered) { this.registered.set(registered); } + + /** + * Just for junit test. {@code encryptedPropertyKeys} is static and grow-only, so it has to be + * reset between test methods. + */ + static void clearEncryptedPropertyKeys() { + encryptedPropertyKeys.clear(); + } } diff --git a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/PolarisRefreshAffectedContextRefresher.java b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/PolarisRefreshAffectedContextRefresher.java index 9211e696d..a7e3d8305 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/PolarisRefreshAffectedContextRefresher.java +++ b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/PolarisRefreshAffectedContextRefresher.java @@ -79,7 +79,7 @@ public class PolarisRefreshAffectedContextRefresher extends PolarisConfigPropert } // update the attribute with @Value annotation for (SpringValue val : targetValues) { - updateSpringValue(val); + updateSpringValue(changedKey, val); } } @@ -101,15 +101,20 @@ public class PolarisRefreshAffectedContextRefresher extends PolarisConfigPropert } } - private void updateSpringValue(SpringValue springValue) { + private void updateSpringValue(String changedKey, SpringValue springValue) { try { Object value = resolvePropertyValue(springValue); springValue.update(value); - LOGGER.info("[SCT Config] Auto update polaris changed value successfully, new value: {}, {}", value, + // values of encrypted config files must not be logged in plain text + Object displayValue = isEncryptedKey(changedKey) ? maskValue(value) : value; + LOGGER.info("[SCT Config] Auto update polaris changed value successfully, new value: {}, {}", displayValue, springValue); } catch (Throwable ex) { + // SpringValue.toString() carries no property value, so it is safe to log as is. + // The stack trace may still embed the raw value (e.g. unresolvable placeholder), + // which is kept on purpose: losing it would make refresh failures undiagnosable. LOGGER.error("[SCT Config] Auto update polaris changed value failed, {}", springValue.toString(), ex); } } diff --git a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/SpringConfigEffectiveValueProvider.java b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/SpringConfigEffectiveValueProvider.java index 092692ede..a6d9a605c 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/SpringConfigEffectiveValueProvider.java +++ b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/SpringConfigEffectiveValueProvider.java @@ -28,6 +28,7 @@ import com.tencent.polaris.configuration.api.core.ConfigKVFile; import com.tencent.polaris.configuration.api.core.ConfigKeyConflict; import com.tencent.polaris.configuration.api.core.EffectiveValue; import com.tencent.polaris.configuration.client.internal.CompositeConfigFile; +import com.tencent.polaris.configuration.client.internal.DefaultConfigFileMetadata; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -103,18 +104,27 @@ public class SpringConfigEffectiveValueProvider implements ConfigEffectiveValueP } String effectiveValue = null; String propertySource = null; + ConfigFileMetadata sourceFile = null; + // Stays UNKNOWN when source attribution is unavailable, so the SDK keeps its + // conservative branch instead of assuming the value is safe to report. + EffectiveValue.SourceKind sourceKind = EffectiveValue.SourceKind.UNKNOWN; try { // Effective value: Environment has already converged by precedence and // resolves ${} placeholders, i.e. the value the application actually reads. effectiveValue = environment.getProperty(key); - propertySource = resolvePropertySourceName(key); + SourceMatch match = resolveSourceMatch(key); + if (match != null) { + propertySource = match.getName(); + sourceFile = match.getFile(); + sourceKind = match.getKind(); + } } catch (Throwable t) { // Only degrade the effective-value dimension; the file value already in hand is returned. LOG.warn("[SCT Config] Resolve effective value failed, key = {}, error = {}", key, t.getClass().getSimpleName()); } - return new EffectiveValue(fileValue, effectiveValue, propertySource); + return new EffectiveValue(fileValue, effectiveValue, propertySource, sourceFile, sourceKind); } @Override @@ -138,15 +148,15 @@ public class SpringConfigEffectiveValueProvider implements ConfigEffectiveValueP } /** - * Walks the ordered PropertySource chain of the Environment and returns the source - * identity of the first one containing the key. Iteration order of - * MutablePropertySources is exactly Spring's precedence order. + * Walks the ordered PropertySource chain of the Environment and returns the match for + * the first source containing the key. Iteration order of MutablePropertySources is + * exactly Spring's precedence order. */ - private String resolvePropertySourceName(String key) { + private SourceMatch resolveSourceMatch(String key) { for (PropertySource source : environment.getPropertySources()) { - String name = matchSource(source, key); - if (name != null) { - return name; + SourceMatch match = matchSource(source, key); + if (match != null) { + return match; } } return null; @@ -157,17 +167,18 @@ public class SpringConfigEffectiveValueProvider implements ConfigEffectiveValueP * in bootstrap mode polaris sources are wrapped into a CompositePropertySource * ("polaris-config") and then into BootstrapPropertySource, so recursion is needed * to reach the real PolarisPropertySource. Polaris sources are translated to a - * normalized coordinate {@code polaris:namespace/group/fileName}. + * normalized coordinate {@code polaris:namespace/group/fileName} plus the structured + * file coordinate the SDK needs to tell whether that source file is encrypted. */ - private String matchSource(PropertySource source, String key) { + private SourceMatch matchSource(PropertySource source, String key) { if (CONFIGURATION_PROPERTIES_SOURCE_NAME.equals(source.getName())) { return null; } if (source instanceof CompositePropertySource) { for (PropertySource sub : ((CompositePropertySource) source).getPropertySources()) { - String name = matchSource(sub, key); - if (name != null) { - return name; + SourceMatch match = matchSource(sub, key); + if (match != null) { + return match; } } return null; @@ -186,21 +197,25 @@ public class SpringConfigEffectiveValueProvider implements ConfigEffectiveValueP // file containing the key is the effective one. for (ConfigKVFile sub : ((CompositeConfigFile) file).getConfigKVFiles()) { if (sub.getProperty(key, null) != null) { - return formatCoordinate(sub); + return toMatch(sub); } } // Fallback: the composite's sub list is frozen at startup and does not cover // files added to the group at runtime; look them up by group coordinate. ConfigKVFile added = findInGroup(polarisSource.getNamespace(), polarisSource.getGroup(), key); if (added != null) { - return formatCoordinate(added); + return toMatch(added); } } else { - return formatCoordinate(file); + return toMatch(file); } } - return source.getName(); + // Not a polaris config file (command line, system properties, environment variables, + // local files...). No coordinate to give, but the value provably does not come from + // the config server, hence cannot be an encrypted config's plaintext: say so + // explicitly rather than leaving the SDK to guess and omit the effective value. + return new SourceMatch(source.getName(), null, EffectiveValue.SourceKind.EXTERNAL); } /** @@ -275,6 +290,12 @@ public class SpringConfigEffectiveValueProvider implements ConfigEffectiveValueP && Objects.equals(candidate.getFileName(), metadata.getFileName()); } + private SourceMatch toMatch(ConfigKVFile file) { + return new SourceMatch(formatCoordinate(file), + new DefaultConfigFileMetadata(file.getNamespace(), file.getFileGroup(), file.getFileName()), + EffectiveValue.SourceKind.POLARIS_FILE); + } + private String formatCoordinate(ConfigKVFile file) { return SOURCE_PREFIX + file.getNamespace() + "/" + file.getFileGroup() + "/" + file.getFileName(); } @@ -285,4 +306,37 @@ public class SpringConfigEffectiveValueProvider implements ConfigEffectiveValueP } return metadata.getNamespace() + "/" + metadata.getFileGroup() + "/" + metadata.getFileName(); } + + /** + * A matched property source: the display identity, the attribution verdict, and — for + * polaris config files — the structured coordinate. The coordinate lets the SDK look up + * that file's own snapshot to decide whether the effective value came from an encrypted + * file; it is consumed inside the SDK and never reported. + */ + private static final class SourceMatch { + + private final String name; + + private final ConfigFileMetadata file; + + private final EffectiveValue.SourceKind kind; + + SourceMatch(String name, ConfigFileMetadata file, EffectiveValue.SourceKind kind) { + this.name = name; + this.file = file; + this.kind = kind; + } + + String getName() { + return name; + } + + ConfigFileMetadata getFile() { + return file; + } + + EffectiveValue.SourceKind getKind() { + return kind; + } + } } diff --git a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/config/PolarisConfigProperties.java b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/config/PolarisConfigProperties.java index 3d2d50221..3e5ab44ec 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/config/PolarisConfigProperties.java +++ b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/config/PolarisConfigProperties.java @@ -86,7 +86,7 @@ public class PolarisConfigProperties { private String dataSource = "polaris"; /** - * The root path of config files, only used in local mode. + * The root path of local config files and remote config fallback caches. */ private String localFileRootPath = "./polaris/backup/config"; diff --git a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/endpoint/PolarisConfigEndpoint.java b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/endpoint/PolarisConfigEndpoint.java index 04788b6d0..d3bac8ade 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/endpoint/PolarisConfigEndpoint.java +++ b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/endpoint/PolarisConfigEndpoint.java @@ -17,14 +17,18 @@ package com.tencent.cloud.polaris.config.endpoint; -import java.util.HashMap; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import com.tencent.cloud.polaris.config.PolarisConfigSDKContextManager; import com.tencent.cloud.polaris.config.adapter.PolarisPropertySource; import com.tencent.cloud.polaris.config.adapter.PolarisPropertySourceManager; +import com.tencent.cloud.polaris.config.config.ConfigFileGroup; import com.tencent.cloud.polaris.config.config.PolarisConfigProperties; +import com.tencent.polaris.configuration.api.core.ConfigKVFile; +import com.tencent.polaris.configuration.client.internal.CompositeConfigFile; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; @@ -45,15 +49,97 @@ public class PolarisConfigEndpoint { @ReadOperation public Map polarisConfig() { - Map configInfo = new HashMap<>(); - configInfo.put("PolarisConfigProperties", polarisConfigProperties); + Map configInfo = new LinkedHashMap<>(); + configInfo.put("PolarisConfigProperties", configProperties()); + configInfo.put("PolarisPropertySource", propertySources()); + configInfo.put("ClientId", getClientId()); + return configInfo; + } - List propertySourceList = PolarisPropertySourceManager.getAllPropertySources(); - configInfo.put("PolarisPropertySource", propertySourceList); + private Map configProperties() { + Map properties = new LinkedHashMap<>(); + properties.put("enabled", polarisConfigProperties.isEnabled()); + properties.put("address", polarisConfigProperties.getAddress()); + properties.put("port", polarisConfigProperties.getPort()); + properties.put("autoRefresh", polarisConfigProperties.isAutoRefresh()); + properties.put("shutdownIfConnectToConfigServerFailed", + polarisConfigProperties.isShutdownIfConnectToConfigServerFailed()); + properties.put("preference", polarisConfigProperties.isPreference()); + properties.put("refreshType", polarisConfigProperties.getRefreshType()); + properties.put("groups", configFileGroups(polarisConfigProperties.getGroups())); + properties.put("dataSource", polarisConfigProperties.getDataSource()); + properties.put("localFileRootPath", polarisConfigProperties.getLocalFileRootPath()); + properties.put("internalEnabled", polarisConfigProperties.isInternalEnabled()); + properties.put("checkAddress", polarisConfigProperties.isCheckAddress()); + properties.put("emptyProtectionEnabled", polarisConfigProperties.isEmptyProtectionEnabled()); + properties.put("emptyProtectionExpiredInterval", polarisConfigProperties.getEmptyProtectionExpiredInterval()); - configInfo.put("ClientId", getClientId()); + PolarisConfigProperties.Report reportProperties = polarisConfigProperties.getReport(); + if (reportProperties != null) { + Map report = new LinkedHashMap<>(); + report.put("enabled", reportProperties.isEnabled()); + if (reportProperties.getEffective() != null) { + Map effective = new LinkedHashMap<>(); + effective.put("enabled", reportProperties.getEffective().isEnabled()); + report.put("effective", effective); + } + properties.put("report", report); + } + // token is intentionally omitted: an actuator endpoint must not expose credentials. + return properties; + } - return configInfo; + private List> configFileGroups(List groups) { + List> groupInfo = new ArrayList<>(); + if (groups == null) { + return groupInfo; + } + for (ConfigFileGroup group : groups) { + Map info = new LinkedHashMap<>(); + info.put("namespace", group.getNamespace()); + info.put("name", group.getName()); + info.put("files", group.getFiles() == null ? new ArrayList<>() : new ArrayList<>(group.getFiles())); + groupInfo.add(info); + } + return groupInfo; + } + + private List> propertySources() { + List> sources = new ArrayList<>(); + for (PolarisPropertySource source : PolarisPropertySourceManager.getAllPropertySources()) { + Map sourceInfo = new LinkedHashMap<>(); + sourceInfo.put("namespace", source.getNamespace()); + sourceInfo.put("group", source.getGroup()); + sourceInfo.put("fileName", source.getFileName()); + sourceInfo.put("propertyNames", new ArrayList<>(source.getSource().keySet())); + sourceInfo.put("configKVFile", configFileInfo(source.getConfigKVFile())); + sources.add(sourceInfo); + } + return sources; + } + + private Map configFileInfo(ConfigKVFile configFile) { + Map info = new LinkedHashMap<>(); + if (configFile == null) { + return info; + } + info.put("namespace", configFile.getNamespace()); + info.put("fileGroup", configFile.getFileGroup()); + info.put("fileName", configFile.getFileName()); + info.put("fileVersion", configFile.getFileVersion()); + info.put("propertyNames", configFile.getPropertyNames() == null + ? new ArrayList<>() : new ArrayList<>(configFile.getPropertyNames())); + if (configFile instanceof CompositeConfigFile) { + List> files = new ArrayList<>(); + List configFiles = ((CompositeConfigFile) configFile).getConfigKVFiles(); + if (configFiles != null) { + for (ConfigKVFile file : configFiles) { + files.add(configFileInfo(file)); + } + } + info.put("configKVFiles", files); + } + return info; } /** diff --git a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/utils/PolarisPropertySourceUtils.java b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/utils/PolarisPropertySourceUtils.java index 5f9ae8404..e3b3e9a47 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/utils/PolarisPropertySourceUtils.java +++ b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/utils/PolarisPropertySourceUtils.java @@ -82,9 +82,19 @@ public final class PolarisPropertySourceUtils { } if (LOGGER.isDebugEnabled()) { - LOGGER.debug("namespace='" + namespace + '\'' - + ", group='" + group + '\'' + ", fileName='" + compositeConfigFile + '\'' - + ", map='" + map + '\''); + // Encryption is a per-file flag and a group merges several files, so a single + // encrypted member is enough to keep every value out of the log: the merged map + // gives no per-key attribution here. With none encrypted the original behaviour + // is preserved and values are logged as before. + if (compositeConfigFile.isEncrypted()) { + LOGGER.debug("[SCT Config] load group property source. namespace = {}, group = {}, " + + "propertyCount = {}, keys = {} (values omitted: group holds encrypted files)", + namespace, group, map.size(), map.keySet()); + } + else { + LOGGER.debug("[SCT Config] load group property source. namespace = {}, group = {}, " + + "propertyCount = {}, map = {}", namespace, group, map.size(), map); + } } return new PolarisPropertySource(namespace, group, "", compositeConfigFile, map); diff --git a/spring-cloud-starter-tencent-polaris-config/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-cloud-starter-tencent-polaris-config/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 874fd0497..8ffcdd5a1 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-cloud-starter-tencent-polaris-config/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -83,7 +83,7 @@ "name": "spring.cloud.polaris.config.local-file-root-path", "type": "java.lang.String", "defaultValue": "./polaris/backup/config", - "description": "Where to load config file, polaris or local." + "description": "The root path of local config files and remote config fallback caches." }, { "name": "spring.cloud.polaris.config.report.enabled", diff --git a/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/ConfigurationModifierTest.java b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/ConfigurationModifierTest.java index 84558f28a..756f27dab 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/ConfigurationModifierTest.java +++ b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/ConfigurationModifierTest.java @@ -18,6 +18,8 @@ package com.tencent.cloud.polaris.config; import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -51,6 +53,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -94,6 +97,9 @@ class ConfigurationModifierTest { @Mock private ConfigEffectiveQueryConfig configEffectiveCustomizer; + @TempDir + private Path tempDir; + private ConfigurationModifier configurationModifier; @BeforeEach @@ -318,6 +324,7 @@ class ConfigurationModifierTest { when(polarisContextProperties.getEnabled()).thenReturn(true); when(polarisConfigProperties.isEnabled()).thenReturn(true); when(polarisConfigProperties.getDataSource()).thenReturn("polaris"); + when(polarisConfigProperties.getLocalFileRootPath()).thenReturn("/tmp/polaris-config-cache"); when(polarisConfigProperties.getAddress()).thenReturn("grpc://127.0.0.1:8093"); when(polarisConfigProperties.isCheckAddress()).thenReturn(false); when(polarisConfigProperties.isEmptyProtectionEnabled()).thenReturn(true); @@ -343,6 +350,7 @@ class ConfigurationModifierTest { // Assert ConnectorConfigImpl connectorConfig = configuration.getConfigFile().getServerConnector(); + verify(connectorConfig).setPersistDir("/tmp/polaris-config-cache"); verify(connectorConfig).setAddresses(parsedAddresses); verify(connectorConfig).setLbPolicy("roundRobin"); verify(connectorConfig).setServerSwitchInterval(600000L); @@ -699,6 +707,121 @@ class ConfigurationModifierTest { } } + /** + * Test an inaccessible address when an existing local cache can be used. + * Scenario: checkAddress and shutdown are enabled, but SDK fallback is enabled and the + * configured persist directory contains a cache file. + * Expect: the preflight check does not abort startup, allowing the SDK to load the cache. + */ + @DisplayName("modify should continue to local cache when config address is unavailable") + @Test + void testModify_CheckAddressNotAccessibleWithLocalCacheFallback() throws Exception { + ConfigurationImpl configuration = buildMockConfiguration(); + ConnectorConfigImpl connectorConfig = configuration.getConfigFile().getServerConnector(); + Path cacheFile = Files.writeString(tempDir.resolve("default#group#application.properties.yaml"), + "content: cached"); + + when(polarisContextProperties.getEnabled()).thenReturn(true); + when(polarisConfigProperties.isEnabled()).thenReturn(true); + when(polarisConfigProperties.getDataSource()).thenReturn("polaris"); + when(polarisConfigProperties.getLocalFileRootPath()).thenReturn(tempDir.toString()); + when(polarisConfigProperties.getAddress()).thenReturn("grpc://127.0.0.1:1"); + when(polarisConfigProperties.isCheckAddress()).thenReturn(true); + when(polarisConfigProperties.isShutdownIfConnectToConfigServerFailed()).thenReturn(true); + when(polarisConfigProperties.isEmptyProtectionEnabled()).thenReturn(true); + when(polarisConfigProperties.getEmptyProtectionExpiredInterval()).thenReturn(604800000L); + when(polarisCryptoConfigProperties.isEnabled()).thenReturn(false); + when(polarisContextProperties.getAddress()).thenReturn("grpc://127.0.0.1:8091"); + when(polarisContextProperties.getAddressLbPolicy()).thenReturn("roundRobin"); + when(polarisContextProperties.getServerSwitchInterval()).thenReturn(600000L); + when(connectorConfig.getFallbackToLocalCache()).thenReturn(true); + + List parsedAddresses = Collections.singletonList("127.0.0.1:1"); + List parsedPolarisAddresses = Collections.singletonList("127.0.0.1:8091"); + try (MockedStatic mockedAddressUtils = Mockito.mockStatic(AddressUtils.class); + MockedStatic mockedTsf = Mockito.mockStatic(TsfContextUtils.class)) { + mockedAddressUtils.when(() -> AddressUtils.parseAddressList("grpc://127.0.0.1:1")) + .thenReturn(parsedAddresses); + mockedAddressUtils.when(() -> AddressUtils.parseAddressList("grpc://127.0.0.1:8091")) + .thenReturn(parsedPolarisAddresses); + mockedAddressUtils.when(() -> AddressUtils.accessible("127.0.0.1", 1, 3000)) + .thenReturn(false); + mockedTsf.when(TsfContextUtils::isOnlyTsfConsulEnabled).thenReturn(false); + + configurationModifier.modify(configuration); + + verify(connectorConfig).setPersistDir(tempDir.toString()); + verify(connectorConfig).setAddresses(parsedAddresses); + assertThat(cacheFile).exists(); + } + } + + /** + * Fallback is enabled but the persist directory has no usable cache file. + * Expect: startup still aborts. + */ + @DisplayName("modify should still abort when fallback is enabled but cache dir is empty") + @Test + void testModify_CheckAddressNotAccessibleWithEmptyCacheDir() { + ConfigurationImpl configuration = buildMockConfiguration(); + ConnectorConfigImpl connectorConfig = configuration.getConfigFile().getServerConnector(); + + when(polarisContextProperties.getEnabled()).thenReturn(true); + when(polarisConfigProperties.isEnabled()).thenReturn(true); + when(polarisConfigProperties.getDataSource()).thenReturn("polaris"); + when(polarisConfigProperties.getLocalFileRootPath()).thenReturn(tempDir.toString()); + when(polarisConfigProperties.getAddress()).thenReturn("grpc://127.0.0.1:1"); + when(polarisConfigProperties.isCheckAddress()).thenReturn(true); + when(polarisConfigProperties.isShutdownIfConnectToConfigServerFailed()).thenReturn(true); + when(connectorConfig.getFallbackToLocalCache()).thenReturn(true); + + List parsedAddresses = Collections.singletonList("127.0.0.1:1"); + try (MockedStatic mockedAddressUtils = Mockito.mockStatic(AddressUtils.class)) { + mockedAddressUtils.when(() -> AddressUtils.parseAddressList("grpc://127.0.0.1:1")) + .thenReturn(parsedAddresses); + mockedAddressUtils.when(() -> AddressUtils.accessible("127.0.0.1", 1, 3000)) + .thenReturn(false); + + assertThatThrownBy(() -> configurationModifier.modify(configuration)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("can not be connected"); + } + } + + /** + * A leftover yaml that only contains '#' must not count as a Polaris persist cache. + */ + @DisplayName("modify should ignore non-cache yaml files when deciding local fallback") + @Test + void testModify_CheckAddressNotAccessibleWithInvalidCacheFileName() throws Exception { + ConfigurationImpl configuration = buildMockConfiguration(); + ConnectorConfigImpl connectorConfig = configuration.getConfigFile().getServerConnector(); + Files.writeString(tempDir.resolve("not-a-cache.yaml"), "content"); + Files.writeString(tempDir.resolve("only#two.yaml"), "content"); + Files.createFile(tempDir.resolve("default#group#application.properties.yaml")); + + when(polarisContextProperties.getEnabled()).thenReturn(true); + when(polarisConfigProperties.isEnabled()).thenReturn(true); + when(polarisConfigProperties.getDataSource()).thenReturn("polaris"); + when(polarisConfigProperties.getLocalFileRootPath()).thenReturn(tempDir.toString()); + when(polarisConfigProperties.getAddress()).thenReturn("grpc://127.0.0.1:1"); + when(polarisConfigProperties.isCheckAddress()).thenReturn(true); + when(polarisConfigProperties.isShutdownIfConnectToConfigServerFailed()).thenReturn(true); + when(connectorConfig.getFallbackToLocalCache()).thenReturn(true); + + List parsedAddresses = Collections.singletonList("127.0.0.1:1"); + try (MockedStatic mockedAddressUtils = Mockito.mockStatic(AddressUtils.class)) { + mockedAddressUtils.when(() -> AddressUtils.parseAddressList("grpc://127.0.0.1:1")) + .thenReturn(parsedAddresses); + mockedAddressUtils.when(() -> AddressUtils.accessible("127.0.0.1", 1, 3000)) + .thenReturn(false); + + assertThatThrownBy(() -> configurationModifier.modify(configuration)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("can not be connected"); + } + } + /** * Test modify with checkAddress enabled, address not accessible but shutdown disabled. * Scenario: checkAddress is true, accessible returns false, shutdownIfConnectToConfigServerFailed is false. diff --git a/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/MockedConfigKVFile.java b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/MockedConfigKVFile.java index 95dbcbb50..77accc2ac 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/MockedConfigKVFile.java +++ b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/MockedConfigKVFile.java @@ -45,6 +45,8 @@ public class MockedConfigKVFile implements ConfigKVFile { private final List listeners = new ArrayList<>(); + private boolean encrypted; + public MockedConfigKVFile(Map properties) { this.properties = properties; } @@ -197,4 +199,13 @@ public class MockedConfigKVFile implements ConfigKVFile { public String getFileVersion() { return ""; } + + @Override + public boolean isEncrypted() { + return encrypted; + } + + public void setEncrypted(boolean encrypted) { + this.encrypted = encrypted; + } } diff --git a/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/PolarisConfigSensitiveDataMaskingTest.java b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/PolarisConfigSensitiveDataMaskingTest.java new file mode 100644 index 000000000..67d4f8bc7 --- /dev/null +++ b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/PolarisConfigSensitiveDataMaskingTest.java @@ -0,0 +1,564 @@ +/* + * Tencent is pleased to support the open source community by making spring-cloud-tencent available. + * + * Copyright (C) 2021 Tencent. All rights reserved. + * + * Licensed under the BSD 3-Clause License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/BSD-3-Clause + * + * 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 com.tencent.cloud.polaris.config.adapter; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.tencent.cloud.polaris.config.config.PolarisConfigProperties; +import com.tencent.cloud.polaris.config.spring.property.PlaceholderHelper; +import com.tencent.cloud.polaris.config.spring.property.SpringValue; +import com.tencent.cloud.polaris.config.spring.property.SpringValueRegistry; +import com.tencent.cloud.polaris.config.utils.PolarisPropertySourceUtils; +import com.tencent.polaris.api.plugin.common.ValueContext; +import com.tencent.polaris.api.plugin.compose.Extensions; +import com.tencent.polaris.api.plugin.configuration.ConfigFile; +import com.tencent.polaris.client.api.SDKContext; +import com.tencent.polaris.configuration.api.core.ChangeType; +import com.tencent.polaris.configuration.api.core.ConfigFileService; +import com.tencent.polaris.configuration.api.core.ConfigKVFileChangeEvent; +import com.tencent.polaris.configuration.api.core.ConfigPropertyChangeInfo; +import com.tencent.polaris.configuration.client.internal.CompositeConfigFile; +import com.tencent.polaris.configuration.client.internal.RevisableConfigFileGroup; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.slf4j.LoggerFactory; + +import org.springframework.beans.TypeConverter; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.cloud.context.refresh.ContextRefresher; +import org.springframework.context.ConfigurableApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Test for sensitive data masking of encrypted config files. + * + *

Covers the log-masking behaviour of {@link PolarisConfigPropertyAutoRefresher}, + * {@link PolarisRefreshAffectedContextRefresher} and {@link PolarisPropertySourceUtils}. + * + * @author evelynwei + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class PolarisConfigSensitiveDataMaskingTest { + + private static final String SENSITIVE_VALUE = "root-password-1234"; + + private static final String PLAIN_VALUE = "plain-value"; + + private final String testNamespace = "testNamespace"; + + private final String testFileGroup = "testFileGroup"; + + private final String testFileName = "application.properties"; + + @Mock + private PolarisConfigProperties polarisConfigProperties; + + @Mock + private SpringValueRegistry springValueRegistry; + + @Mock + private PlaceholderHelper placeholderHelper; + + @Mock + private ConfigFileService configFileService; + + @Mock + private ContextRefresher contextRefresher; + + @Mock + private SDKContext sdkContext; + + @Mock + private Extensions extensions; + + @Mock + private ValueContext valueContext; + + private final List> appenders = new ArrayList<>(); + + @BeforeEach + public void setUp() { + PolarisPropertySourceManager.clearPropertySources(); + PolarisConfigPropertyAutoRefresher.clearEncryptedPropertyKeys(); + } + + @AfterEach + public void tearDown() { + PolarisConfigPropertyAutoRefresher.clearEncryptedPropertyKeys(); + for (ListAppender appender : appenders) { + appender.stop(); + } + appenders.clear(); + } + + /** + * Item 5: maskValue keeps the length and fingerprint hints and never exposes the content. + */ + @Test + public void testMaskValue() { + assertThat(PolarisConfigPropertyAutoRefresher.maskValue(null)).isNull(); + assertThat(PolarisConfigPropertyAutoRefresher.maskValue("")).isEmpty(); + assertThat(PolarisConfigPropertyAutoRefresher.maskValue(SENSITIVE_VALUE)) + .matches("\\*\\*\\*\\(len=" + SENSITIVE_VALUE.length() + ", fp=[0-9a-f]{8}\\)") + .doesNotContain(SENSITIVE_VALUE); + // non-String Object must not blow up + assertThat(PolarisConfigPropertyAutoRefresher.maskValue(12345)).matches("\\*\\*\\*\\(len=5, fp=[0-9a-f]{8}\\)"); + assertThat(PolarisConfigPropertyAutoRefresher.maskValue(Boolean.TRUE)) + .matches("\\*\\*\\*\\(len=4, fp=[0-9a-f]{8}\\)"); + } + + /** + * Item 10: the fingerprint is stable for the same value and differs for different values of the + * same length. This is what makes an old/new pair distinguishable when their lengths match. + */ + @Test + public void testFingerprintDistinguishesEqualLengthValues() { + String oldValue = "passwordAAAA"; + String newValue = "passwordBBBB"; + assertThat(oldValue).hasSameSizeAs(newValue); + + String maskedOld = PolarisConfigPropertyAutoRefresher.maskValue(oldValue); + String maskedNew = PolarisConfigPropertyAutoRefresher.maskValue(newValue); + + assertThat(maskedOld).isNotEqualTo(maskedNew); + // stable within the same process, so repeated logging of one value reads consistently + assertThat(PolarisConfigPropertyAutoRefresher.maskValue(oldValue)).isEqualTo(maskedOld); + } + + /** + * Item 3: keys of an encrypted file are registered, keys of a plain file are not. + */ + @Test + public void testEncryptedKeysRegisteredOnlyForEncryptedFile() { + PolarisConfigPropertyAutoRefresher refresher = buildRefresher(); + + MockedConfigKVFile encryptedFile = new MockedConfigKVFile(contentOf("encrypted.key", SENSITIVE_VALUE)); + fireChange(refresher, encryptedFile, encryptedConfigFile(), "encrypted.key", + new ConfigPropertyChangeInfo("encrypted.key", "old", SENSITIVE_VALUE, ChangeType.MODIFIED)); + assertThat(refresher.isEncryptedKey("encrypted.key")).isTrue(); + + MockedConfigKVFile plainFile = new MockedConfigKVFile(contentOf("plain.key", PLAIN_VALUE)); + fireChange(refresher, plainFile, plainConfigFile(), "plain.key", + new ConfigPropertyChangeInfo("plain.key", "old", PLAIN_VALUE, ChangeType.MODIFIED)); + assertThat(refresher.isEncryptedKey("plain.key")).isFalse(); + } + + /** + * Item 4: a null ConfigFile (ConfigKVFileChangeEvent#getConfigFile may be null) must neither + * throw nor register anything. + */ + @Test + public void testNullConfigFileNeitherThrowsNorRegisters() { + PolarisConfigPropertyAutoRefresher refresher = buildRefresher(); + + MockedConfigKVFile file = new MockedConfigKVFile(contentOf("some.key", PLAIN_VALUE)); + fireChange(refresher, file, null, "some.key", + new ConfigPropertyChangeInfo("some.key", "old", PLAIN_VALUE, ChangeType.MODIFIED)); + + assertThat(refresher.isEncryptedKey("some.key")).isFalse(); + } + + /** + * Item 1: the change log of an encrypted config file carries no raw value. + */ + @Test + public void testEncryptedConfigChangeLogIsMasked() { + PolarisConfigPropertyAutoRefresher refresher = buildRefresher(); + ListAppender appender = attachAppender(PolarisConfigPropertyAutoRefresher.class); + + MockedConfigKVFile file = new MockedConfigKVFile(contentOf("db.password", SENSITIVE_VALUE)); + fireChange(refresher, file, encryptedConfigFile(), "db.password", + new ConfigPropertyChangeInfo("db.password", SENSITIVE_VALUE, SENSITIVE_VALUE + "-new", + ChangeType.MODIFIED)); + + String logs = renderLogs(appender); + assertThat(logs).doesNotContain(SENSITIVE_VALUE); + // key and change type stay observable for troubleshooting + assertThat(logs).contains("db.password").contains("MODIFIED").contains("***(len="); + } + + /** + * Item 2: a plain config file keeps the original behaviour, i.e. the SDK's own + * ConfigPropertyChangeInfo rendering, with no masking applied by us. + *

+ * That rendering carries the key and the change type but no values, so this asserts the + * absence of the mask marker rather than the presence of the raw value. + */ + @Test + public void testPlainConfigChangeLogIsNotMasked() { + PolarisConfigPropertyAutoRefresher refresher = buildRefresher(); + ListAppender appender = attachAppender(PolarisConfigPropertyAutoRefresher.class); + + MockedConfigKVFile file = new MockedConfigKVFile(contentOf("app.name", PLAIN_VALUE)); + fireChange(refresher, file, plainConfigFile(), "app.name", + new ConfigPropertyChangeInfo("app.name", "old-name", PLAIN_VALUE, ChangeType.MODIFIED)); + + String logs = renderLogs(appender); + assertThat(logs).contains("app.name").contains("MODIFIED").doesNotContain("***(len="); + } + + /** + * Item 6: updateSpringValue masks the new value when the changed key is encrypted, and keeps it + * as is otherwise. + */ + @Test + public void testSpringValueRefreshLogRespectsEncryptedKey() throws Exception { + PolarisRefreshAffectedContextRefresher refresher = buildAffectedRefresher(SENSITIVE_VALUE); + ListAppender appender = attachAppender(PolarisRefreshAffectedContextRefresher.class); + + MockedConfigKVFile encryptedFile = new MockedConfigKVFile(contentOf("db.password", SENSITIVE_VALUE)); + fireChange(refresher, encryptedFile, encryptedConfigFile(), "db.password", + new ConfigPropertyChangeInfo("db.password", "old", SENSITIVE_VALUE, ChangeType.MODIFIED)); + + String logs = renderLogs(appender); + assertThat(logs).contains("Auto update polaris changed value successfully"); + assertThat(logs).doesNotContain(SENSITIVE_VALUE); + assertThat(logs).contains("***(len=" + SENSITIVE_VALUE.length() + ", fp="); + } + + @Test + public void testSpringValueRefreshLogKeepsPlainValue() throws Exception { + PolarisRefreshAffectedContextRefresher refresher = buildAffectedRefresher(PLAIN_VALUE); + ListAppender appender = attachAppender(PolarisRefreshAffectedContextRefresher.class); + + MockedConfigKVFile plainFile = new MockedConfigKVFile(contentOf("app.name", PLAIN_VALUE)); + fireChange(refresher, plainFile, plainConfigFile(), "app.name", + new ConfigPropertyChangeInfo("app.name", "old", PLAIN_VALUE, ChangeType.MODIFIED)); + + String logs = renderLogs(appender); + assertThat(logs).contains(PLAIN_VALUE).doesNotContain("***(len="); + } + + /** + * Item 9: SpringValue#toString carries no property value, which is why the refresh failure log + * needs no masking. + */ + @Test + public void testSpringValueToStringCarriesNoValue() throws Exception { + MockedConfigChange bean = new MockedConfigChange(); + bean.setK1(SENSITIVE_VALUE); + Field field = bean.getClass().getDeclaredField("k1"); + SpringValue springValue = new SpringValue("db.password", "${db.password}", bean, "mockedConfigChange", field); + + assertThat(springValue.toString()) + .doesNotContain(SENSITIVE_VALUE) + .contains("db.password") + .contains("mockedConfigChange"); + } + + /** + * Item 7: a group holding an encrypted file logs key names only, never values. + */ + @Test + public void testGroupPropertySourceDebugLogCarriesNoValueWhenEncrypted() { + String logs = loadGroupAndRenderDebugLogs("db.password", SENSITIVE_VALUE, true); + + assertThat(logs).doesNotContain(SENSITIVE_VALUE); + assertThat(logs).contains("db.password").contains("propertyCount = 1").contains("values omitted"); + } + + /** + * A group with no encrypted file keeps the original behaviour and logs the merged map, so the + * unencrypted scenario loses no diagnosability. + */ + @Test + public void testGroupPropertySourceDebugLogKeepsMapWhenNotEncrypted() { + String logs = loadGroupAndRenderDebugLogs("app.name", PLAIN_VALUE, false); + + assertThat(logs).contains("app.name").contains(PLAIN_VALUE).contains("map = "); + } + + /** + * Loads a one-file group at DEBUG level and returns what the loader logged. + */ + private String loadGroupAndRenderDebugLogs(String key, String value, boolean encrypted) { + ch.qos.logback.classic.Logger logger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(PolarisPropertySourceUtils.class); + Level originalLevel = logger.getLevel(); + logger.setLevel(Level.DEBUG); + ListAppender appender = attachAppender(PolarisPropertySourceUtils.class); + try { + MockedConfigKVFile file = new MockedConfigKVFile(contentOf(key, value), + testFileName, testFileGroup, testNamespace); + file.setEncrypted(encrypted); + com.tencent.polaris.configuration.api.core.ConfigFileGroup group = + new com.tencent.polaris.configuration.client.internal.RevisableConfigFileGroup( + testNamespace, testFileGroup, java.util.Collections.singletonList(file), "v1"); + when(configFileService.getConfigFileGroup(testNamespace, testFileGroup)).thenReturn(group); + when(configFileService.getConfigPropertiesFile(testNamespace, testFileGroup, testFileName)) + .thenReturn(file); + + PolarisPropertySource source = PolarisPropertySourceUtils + .loadGroupPolarisPropertySource(configFileService, testNamespace, testFileGroup); + + assertThat(source).isNotNull(); + return renderLogs(appender); + } + finally { + logger.setLevel(originalLevel); + } + } + + /** + * Item 8: locks the known boundary of the grow-only key set. A key that has never been seen in + * a file-dimension change event is not masked on its first refresh; once registered it stays + * masked even when the very same key later arrives from a plain file. + */ + @Test + public void testGrowOnlyKeySetBoundary() { + PolarisConfigPropertyAutoRefresher refresher = buildRefresher(); + + // never seen before -> not masked + assertThat(refresher.isEncryptedKey("shared.key")).isFalse(); + + MockedConfigKVFile encryptedFile = new MockedConfigKVFile(contentOf("shared.key", SENSITIVE_VALUE)); + fireChange(refresher, encryptedFile, encryptedConfigFile(), "shared.key", + new ConfigPropertyChangeInfo("shared.key", "old", SENSITIVE_VALUE, ChangeType.MODIFIED)); + assertThat(refresher.isEncryptedKey("shared.key")).isTrue(); + + // a later plain file carrying the same key does not un-register it + MockedConfigKVFile plainFile = new MockedConfigKVFile(contentOf("shared.key", PLAIN_VALUE)); + fireChange(refresher, plainFile, plainConfigFile(), "shared.key", + new ConfigPropertyChangeInfo("shared.key", SENSITIVE_VALUE, PLAIN_VALUE, ChangeType.MODIFIED)); + assertThat(refresher.isEncryptedKey("shared.key")).isTrue(); + } + + /** + * An ADDED key may be absent from {@code ConfigKVFile#getPropertyNames()} when the listener + * runs; it must still be registered from {@code changedKeys()}. + */ + @Test + public void testAddedEncryptedKeyIsRegisteredFromChangeEvent() { + PolarisConfigPropertyAutoRefresher refresher = buildRefresher(); + ListAppender appender = attachAppender(PolarisConfigPropertyAutoRefresher.class); + + MockedConfigKVFile file = new MockedConfigKVFile(contentOf("existing.key", PLAIN_VALUE)); + fireChange(refresher, file, encryptedConfigFile(), "db.password", + new ConfigPropertyChangeInfo("db.password", null, SENSITIVE_VALUE, ChangeType.ADDED)); + + assertThat(refresher.isEncryptedKey("db.password")).isTrue(); + assertThat(renderLogs(appender)).doesNotContain(SENSITIVE_VALUE).contains("***(len="); + } + + /** + * A log level is not a secret, so the logging.level line keeps the raw value even when the + * file is encrypted. Encryption is a per-file flag, so it also covers harmless keys. + */ + @Test + public void testEncryptedLoggingLevelChangeKeepsRawValue() { + PolarisConfigPropertyAutoRefresher refresher = buildRefresher(); + ListAppender appender = attachAppender(PolarisConfigPropertyAutoRefresher.class); + + String levelKey = "logging.level.com.example"; + MockedConfigKVFile file = new MockedConfigKVFile(contentOf(levelKey, "DEBUG")); + fireChange(refresher, file, encryptedConfigFile(), levelKey, + new ConfigPropertyChangeInfo(levelKey, "INFO", "DEBUG", ChangeType.MODIFIED)); + + assertThat(renderLogs(appender)).contains("set logging.level loggerName:com.example, newValue:DEBUG"); + } + + /** + * A file newly added to a watched group carries its own encrypted flag, so its keys are + * registered at load time and the first @Value refresh log is already masked. + */ + @Test + public void testGroupAddOfEncryptedFileMasksSpringValueRefreshLog() throws Exception { + PolarisRefreshAffectedContextRefresher refresher = buildAffectedRefresher(SENSITIVE_VALUE); + ListAppender appender = attachAppender(PolarisRefreshAffectedContextRefresher.class); + + fireGroupAdd(refresher, "encrypted-add.properties", SENSITIVE_VALUE, true); + + assertThat(refresher.isEncryptedKey("db.password")).isTrue(); + assertThat(renderLogs(appender)).contains("Auto update polaris changed value successfully") + .doesNotContain(SENSITIVE_VALUE) + .contains("***(len="); + } + + /** + * A plain file added to a watched group keeps the original behaviour and logs the raw value: + * judging per file means the unencrypted scenario loses no diagnosability. + */ + @Test + public void testGroupAddOfPlainFileKeepsRawValue() throws Exception { + PolarisRefreshAffectedContextRefresher refresher = buildAffectedRefresher(PLAIN_VALUE); + ListAppender appender = attachAppender(PolarisRefreshAffectedContextRefresher.class); + + fireGroupAdd(refresher, "plain-add.properties", PLAIN_VALUE, false); + + assertThat(refresher.isEncryptedKey("db.password")).isFalse(); + assertThat(renderLogs(appender)).contains(PLAIN_VALUE).doesNotContain("***(len="); + } + + /** + * Registers a one-file group, then adds {@code addedFileName} carrying {@code addedValue} + * under {@code db.password} and waits for the group-add refresh to land. + *

+ * Each caller must pass a distinct {@code addedFileName}: the registered-property-source set + * behind the group listener is static and grow-only, so a reused name is silently skipped. + */ + private void fireGroupAdd(PolarisRefreshAffectedContextRefresher refresher, String addedFileName, + String addedValue, boolean addedFileEncrypted) throws InterruptedException { + Map existing = new ConcurrentHashMap<>(); + existing.put("app.name", PLAIN_VALUE); + MockedConfigKVFile file = new MockedConfigKVFile(existing, testFileName, testFileGroup, testNamespace); + when(configFileService.getConfigPropertiesFile(testNamespace, testFileGroup, testFileName)) + .thenReturn(file); + + CompositeConfigFile compositeConfigFile = new CompositeConfigFile(Collections.singletonList(file)); + PolarisPropertySource polarisPropertySource = new PolarisPropertySource(testNamespace, testFileGroup, + testFileName, compositeConfigFile, new ConcurrentHashMap<>(existing)); + PolarisPropertySourceManager.addPropertySource(polarisPropertySource); + + RevisableConfigFileGroup group = new RevisableConfigFileGroup(testNamespace, testFileGroup, + Collections.singletonList(file), "v1"); + when(configFileService.getConfigFileGroup(testNamespace, testFileGroup)).thenReturn(group); + when(sdkContext.getExtensions()).thenReturn(extensions); + when(extensions.getValueContext()).thenReturn(valueContext); + + refresher.onApplicationEvent(null); + + Map added = new ConcurrentHashMap<>(); + added.put("db.password", addedValue); + MockedConfigKVFile file2 = new MockedConfigKVFile(added, addedFileName, testFileGroup, testNamespace); + file2.setEncrypted(addedFileEncrypted); + when(configFileService.getConfigPropertiesFile(testNamespace, testFileGroup, addedFileName)) + .thenReturn(file2); + + group.updateConfigFileList(Arrays.asList(file, file2), "v2"); + + long deadline = System.currentTimeMillis() + 5000; + while (System.currentTimeMillis() < deadline && polarisPropertySource.getProperty("db.password") == null) { + Thread.sleep(50); + } + assertThat(polarisPropertySource.getProperty("db.password")).isEqualTo(addedValue); + } + + /** + * The affected-context refresher is used throughout: it is the default implementation and the + * only one whose {@code refreshConfigurationProperties} works against a mocked context. + */ + private PolarisConfigPropertyAutoRefresher buildRefresher() { + try { + return buildAffectedRefresher(PLAIN_VALUE); + } + catch (Exception e) { + throw new IllegalStateException("failed to build refresher", e); + } + } + + private PolarisRefreshAffectedContextRefresher buildAffectedRefresher(String resolvedValue) throws Exception { + when(polarisConfigProperties.isAutoRefresh()).thenReturn(true); + when(sdkContext.getExtensions()).thenReturn(extensions); + when(extensions.getValueContext()).thenReturn(valueContext); + when(valueContext.getClientId()).thenReturn("mockClientId"); + when(valueContext.getHost()).thenReturn("mockHost"); + + PolarisRefreshAffectedContextRefresher refresher = new PolarisRefreshAffectedContextRefresher( + polarisConfigProperties, springValueRegistry, placeholderHelper, configFileService, + contextRefresher, sdkContext); + + ConfigurableApplicationContext applicationContext = mock(ConfigurableApplicationContext.class); + ConfigurableListableBeanFactory beanFactory = mock(ConfigurableListableBeanFactory.class); + TypeConverter typeConverter = mock(TypeConverter.class); + when(beanFactory.getTypeConverter()).thenReturn(typeConverter); + when(applicationContext.getBeanFactory()).thenReturn(beanFactory); + refresher.setApplicationContext(applicationContext); + when(typeConverter.convertIfNecessary(any(), any(), (Field) any())).thenReturn(resolvedValue); + + MockedConfigChange bean = new MockedConfigChange(); + Field field = bean.getClass().getDeclaredField("k1"); + Collection springValues = new ArrayList<>(); + springValues.add(new SpringValue("db.password", "${db.password}", bean, "mockedConfigChange", field)); + when(springValueRegistry.get(any(), any())).thenReturn(springValues); + + return refresher; + } + + /** + * Registers the property source, wires the change listener and fires one change event. + */ + private void fireChange(PolarisConfigPropertyAutoRefresher refresher, MockedConfigKVFile file, + ConfigFile configFile, String changedKey, ConfigPropertyChangeInfo changeInfo) { + Map source = new HashMap<>(contentOf(changedKey, changeInfo.getOldValue())); + PolarisPropertySource propertySource = new PolarisPropertySource(file.getNamespace(), file.getFileGroup(), + file.getFileName(), file, source); + refresher.registerPolarisConfigPublishChangeListener(propertySource); + + Map changeInfos = new HashMap<>(); + changeInfos.put(changedKey, changeInfo); + file.fireChangeListener(new ConfigKVFileChangeEvent(changeInfos, configFile)); + } + + private ConfigFile encryptedConfigFile() { + ConfigFile configFile = new ConfigFile(testNamespace, testFileGroup, testFileName); + configFile.setEncrypted(true); + return configFile; + } + + private ConfigFile plainConfigFile() { + ConfigFile configFile = new ConfigFile(testNamespace, testFileGroup, testFileName); + configFile.setEncrypted(false); + return configFile; + } + + private Map contentOf(String key, Object value) { + Map content = new HashMap<>(); + content.put(key, value); + return content; + } + + private ListAppender attachAppender(Class loggerClass) { + ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(loggerClass); + ListAppender appender = new ListAppender<>(); + appender.setContext(logger.getLoggerContext()); + appender.start(); + logger.addAppender(appender); + appenders.add(appender); + return appender; + } + + private String renderLogs(ListAppender appender) { + StringBuilder builder = new StringBuilder(); + for (ILoggingEvent event : appender.list) { + builder.append(event.getFormattedMessage()).append('\n'); + } + return builder.toString(); + } +} diff --git a/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/PolarisPropertiesSourceAutoRefresherTest.java b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/PolarisPropertiesSourceAutoRefresherTest.java index 50dc8edd0..67a719acb 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/PolarisPropertiesSourceAutoRefresherTest.java +++ b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/PolarisPropertiesSourceAutoRefresherTest.java @@ -100,7 +100,8 @@ public class PolarisPropertiesSourceAutoRefresherTest { @Test public void testConfigFileChanged() throws Exception { PolarisRefreshAffectedContextRefresher refresher = new PolarisRefreshAffectedContextRefresher( - polarisConfigProperties, springValueRegistry, placeholderHelper, configFileService, contextRefresher, sdkContext); + polarisConfigProperties, springValueRegistry, placeholderHelper, configFileService, + contextRefresher, sdkContext); ConfigurableApplicationContext applicationContext = mock(ConfigurableApplicationContext.class); ConfigurableListableBeanFactory beanFactory = mock(ConfigurableListableBeanFactory.class); TypeConverter typeConverter = mock(TypeConverter.class); @@ -159,7 +160,8 @@ public class PolarisPropertiesSourceAutoRefresherTest { @Test public void testConfigFileGroupChanged() throws Exception { PolarisRefreshAffectedContextRefresher refresher = new PolarisRefreshAffectedContextRefresher( - polarisConfigProperties, springValueRegistry, placeholderHelper, configFileService, contextRefresher, sdkContext); + polarisConfigProperties, springValueRegistry, placeholderHelper, configFileService, + contextRefresher, sdkContext); ConfigurableApplicationContext applicationContext = mock(ConfigurableApplicationContext.class); ConfigurableListableBeanFactory beanFactory = mock(ConfigurableListableBeanFactory.class); TypeConverter typeConverter = mock(TypeConverter.class); diff --git a/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/SpringConfigEffectiveValueProviderTest.java b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/SpringConfigEffectiveValueProviderTest.java index 18478f8ed..89eb7d005 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/SpringConfigEffectiveValueProviderTest.java +++ b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/SpringConfigEffectiveValueProviderTest.java @@ -123,6 +123,10 @@ class SpringConfigEffectiveValueProviderTest { assertThat(value.getFileValue()).isEqualTo("8080"); assertThat(value.getEffectiveValue()).isEqualTo("9090"); assertThat(value.getPropertySource()).isEqualTo("commandLineArgs"); + // not a polaris config file: no coordinate to give, but the attribution is explicit, so the + // SDK keeps the effective value instead of omitting it whenever an encrypted file is watched + assertThat(value.getSourceFile()).isNull(); + assertThat(value.getSourceKind()).isEqualTo(EffectiveValue.SourceKind.EXTERNAL); } @Test @@ -140,6 +144,12 @@ class SpringConfigEffectiveValueProviderTest { assertThat(value.getEffectiveValue()).isEqualTo("8081"); // still sourced from the polaris file itself: normalized coordinate assertThat(value.getPropertySource()).isEqualTo("polaris:default/order-service/application.yaml"); + // the structured coordinate lets the SDK look up that file's encryption state + assertThat(value.getSourceKind()).isEqualTo(EffectiveValue.SourceKind.POLARIS_FILE); + assertThat(value.getSourceFile()).isNotNull(); + assertThat(value.getSourceFile().getNamespace()).isEqualTo(NAMESPACE); + assertThat(value.getSourceFile().getFileGroup()).isEqualTo(GROUP); + assertThat(value.getSourceFile().getFileName()).isEqualTo(FILE_NAME); } @Test @@ -199,6 +209,11 @@ class SpringConfigEffectiveValueProviderTest { assertThat(value.getEffectiveValue()).isEqualTo("1"); // property source points to the winning sub file, not the opaque group source name assertThat(value.getPropertySource()).isEqualTo("polaris:default/mygroup/a.yaml"); + // coordinate follows the same winning sub file, so encryption is judged per sub file + assertThat(value.getSourceKind()).isEqualTo(EffectiveValue.SourceKind.POLARIS_FILE); + assertThat(value.getSourceFile()).isNotNull(); + assertThat(value.getSourceFile().getFileName()).isEqualTo("a.yaml"); + assertThat(value.getSourceFile().getFileGroup()).isEqualTo("mygroup"); // conflicts are sub-file grained: b is excluded, a is reported List conflicts = provider.resolveConflicts("k", metadata(NAMESPACE, "mygroup", "b.yaml")); diff --git a/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/endpoint/PolarisConfigEndpointTest.java b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/endpoint/PolarisConfigEndpointTest.java index 51bc92975..cd184b54a 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/endpoint/PolarisConfigEndpointTest.java +++ b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/endpoint/PolarisConfigEndpointTest.java @@ -17,10 +17,11 @@ package com.tencent.cloud.polaris.config.endpoint; -import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; +import com.fasterxml.jackson.databind.ObjectMapper; import com.tencent.cloud.polaris.config.PolarisConfigSDKContextManager; import com.tencent.cloud.polaris.config.adapter.MockedConfigKVFile; import com.tencent.cloud.polaris.config.adapter.PolarisPropertySource; @@ -28,6 +29,7 @@ import com.tencent.cloud.polaris.config.adapter.PolarisPropertySourceManager; import com.tencent.cloud.polaris.config.config.PolarisConfigProperties; import com.tencent.polaris.api.plugin.common.ValueContext; import com.tencent.polaris.client.api.SDKContext; +import com.tencent.polaris.configuration.client.internal.CompositeConfigFile; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -64,20 +66,57 @@ public class PolarisConfigEndpointTest { } @Test - public void testPolarisConfigEndpoint() { + @SuppressWarnings("unchecked") + public void testPolarisConfigEndpoint() throws Exception { + PolarisConfigProperties properties = new PolarisConfigProperties(); + properties.setToken("endpoint-must-not-expose-this-token"); Map content = new HashMap<>(); - content.put("k1", "v1"); - content.put("k2", "v2"); - content.put("k3", "v3"); + content.put("k1", "sensitive-value-one"); + content.put("k2", "sensitive-value-two"); + content.put("k3", "sensitive-value-three"); MockedConfigKVFile file = new MockedConfigKVFile(content); PolarisPropertySource polarisPropertySource = new PolarisPropertySource(testNamespace, testServiceName, testFileName, file, content); PolarisPropertySourceManager.addPropertySource(polarisPropertySource); - PolarisConfigEndpoint endpoint = new PolarisConfigEndpoint(polarisConfigProperties); + PolarisConfigEndpoint endpoint = new PolarisConfigEndpoint(properties); Map info = endpoint.polarisConfig(); - assertThat(polarisConfigProperties).isEqualTo(info.get("PolarisConfigProperties")); - assertThat(Collections.singletonList(polarisPropertySource)).isEqualTo(info.get("PolarisPropertySource")); + assertThat(info.get("PolarisConfigProperties")).isInstanceOf(Map.class); + List> sources = (List>) info.get("PolarisPropertySource"); + assertThat(sources).hasSize(1); + assertThat(sources.get(0)).containsEntry("namespace", testNamespace) + .containsEntry("group", testServiceName) + .containsEntry("fileName", testFileName); + + // Actuator serializes the return value. Keep it to plain DTO structures and never expose + // property values through this diagnostic endpoint. + String json = new ObjectMapper().writeValueAsString(info); + assertThat(json).contains("\"ClientId\":null", "\"propertyNames\":[") + .doesNotContain("sensitive-value-one", "sensitive-value-two", "sensitive-value-three", + "endpoint-must-not-expose-this-token"); + } + + @Test + @SuppressWarnings("unchecked") + public void testPolarisConfigEndpointExposesCompositeFileMetadata() { + MockedConfigKVFile first = new MockedConfigKVFile(Map.of("first.key", "first-value"), + "first.properties", testServiceName, testNamespace); + MockedConfigKVFile second = new MockedConfigKVFile(Map.of("second.key", "second-value"), + "second.properties", testServiceName, testNamespace); + CompositeConfigFile composite = new CompositeConfigFile(List.of(first, second)); + PolarisPropertySource source = new PolarisPropertySource(testNamespace, testServiceName, "", + composite, new HashMap<>()); + PolarisPropertySourceManager.addPropertySource(source); + + PolarisConfigEndpoint endpoint = new PolarisConfigEndpoint(polarisConfigProperties); + List> sources = + (List>) endpoint.polarisConfig().get("PolarisPropertySource"); + Map configKVFile = (Map) sources.get(0).get("configKVFile"); + List> files = + (List>) configKVFile.get("configKVFiles"); + + assertThat(files).extracting(file -> file.get("fileName")) + .containsExactly("first.properties", "second.properties"); } @Test