feat: sensitive data encryption (#1818)

* feat: mask encrypted config values in SCT logs

Keep plaintext out of refresh and group-load logs by tracking encrypted keys and replacing values with length plus a per-process salted fingerprint.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: keep config fallback and polarisconfig endpoint usable

Serialize actuator polarisconfig as safe DTOs so ClientId is reachable, honor local-file-root-path for remote cache, and continue startup from existing cache when the config server is unreachable.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: attribute encrypted config by file for logs and ACK

Pass EffectiveValue.sourceFile to the SDK, register keys from ConfigKVFile.isEncrypted() on group add, and omit group DEBUG maps only when the composite file is encrypted.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: mark non-Polaris effective values as EXTERNAL

Tell the SDK that command-line and env overrides are not config-center files, so ACK can keep those effective values when an encrypted file is also watched.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(docs): 更新 CHANGELOG 链接至 PR 1818

Signed-off-by: evelynwei <evelynwei@tencent.com>

* refactor: hide test-only encrypted key set reset

Keep clearEncryptedPropertyKeys() package-private so it is not part of the public API.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Signed-off-by: evelynwei <evelynwei@tencent.com>
Co-authored-by: evelynwei <evelynwei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2025.1
evelynwei 3 days ago committed by evelynwei
parent b170150f30
commit 396928d34d

@ -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)

@ -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<String> configAddresses) {
private void checkAddressAccessible(List<String> 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]);
}
}

@ -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<String> registeredPolarisPropertySets = Sets.newConcurrentHashSet();
/**
* Property keys contributed by encrypted config files. Values of these keys must never be
* written to logs in plain text.
* <p>
* 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<String> 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<String, Object> effectSource = effectPolarisPropertySource.getSource();
Map<String, Object> 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.
* <p>
* {@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<String> changedKeys) {
if (configFile == null || !configFile.isEncrypted()) {
return;
}
if (kvFile != null) {
Set<String> 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.
* <p>
* 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<String> 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.
* <p>
* 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).
* <p>
* 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.
* <p>
* 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<String, ConfigFileMetadata> calculateUnregister(List<ConfigFileMetadata> oldConfigFileMetadataList,
List<ConfigFileMetadata> 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();
}
}

@ -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);
}
}

@ -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;
}
}
}

@ -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";

@ -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<String, Object> polarisConfig() {
Map<String, Object> configInfo = new HashMap<>();
configInfo.put("PolarisConfigProperties", polarisConfigProperties);
Map<String, Object> configInfo = new LinkedHashMap<>();
configInfo.put("PolarisConfigProperties", configProperties());
configInfo.put("PolarisPropertySource", propertySources());
configInfo.put("ClientId", getClientId());
return configInfo;
}
List<PolarisPropertySource> propertySourceList = PolarisPropertySourceManager.getAllPropertySources();
configInfo.put("PolarisPropertySource", propertySourceList);
private Map<String, Object> configProperties() {
Map<String, Object> 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<String, Object> report = new LinkedHashMap<>();
report.put("enabled", reportProperties.isEnabled());
if (reportProperties.getEffective() != null) {
Map<String, Object> 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<Map<String, Object>> configFileGroups(List<ConfigFileGroup> groups) {
List<Map<String, Object>> groupInfo = new ArrayList<>();
if (groups == null) {
return groupInfo;
}
for (ConfigFileGroup group : groups) {
Map<String, Object> 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<Map<String, Object>> propertySources() {
List<Map<String, Object>> sources = new ArrayList<>();
for (PolarisPropertySource source : PolarisPropertySourceManager.getAllPropertySources()) {
Map<String, Object> 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<String, Object> configFileInfo(ConfigKVFile configFile) {
Map<String, Object> 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<Map<String, Object>> files = new ArrayList<>();
List<ConfigKVFile> configFiles = ((CompositeConfigFile) configFile).getConfigKVFiles();
if (configFiles != null) {
for (ConfigKVFile file : configFiles) {
files.add(configFileInfo(file));
}
}
info.put("configKVFiles", files);
}
return info;
}
/**

@ -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);

@ -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",

@ -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<String> parsedAddresses = Collections.singletonList("127.0.0.1:1");
List<String> parsedPolarisAddresses = Collections.singletonList("127.0.0.1:8091");
try (MockedStatic<AddressUtils> mockedAddressUtils = Mockito.mockStatic(AddressUtils.class);
MockedStatic<TsfContextUtils> 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<String> parsedAddresses = Collections.singletonList("127.0.0.1:1");
try (MockedStatic<AddressUtils> 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<String> parsedAddresses = Collections.singletonList("127.0.0.1:1");
try (MockedStatic<AddressUtils> 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.

@ -45,6 +45,8 @@ public class MockedConfigKVFile implements ConfigKVFile {
private final List<ConfigKVFileChangeListener> listeners = new ArrayList<>();
private boolean encrypted;
public MockedConfigKVFile(Map<String, Object> 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;
}
}

@ -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.
*
* <p>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<ListAppender<ILoggingEvent>> appenders = new ArrayList<>();
@BeforeEach
public void setUp() {
PolarisPropertySourceManager.clearPropertySources();
PolarisConfigPropertyAutoRefresher.clearEncryptedPropertyKeys();
}
@AfterEach
public void tearDown() {
PolarisConfigPropertyAutoRefresher.clearEncryptedPropertyKeys();
for (ListAppender<ILoggingEvent> 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<ILoggingEvent> 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.
* <p>
* 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<ILoggingEvent> 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<ILoggingEvent> 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<ILoggingEvent> 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<ILoggingEvent> 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<ILoggingEvent> 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<ILoggingEvent> 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<ILoggingEvent> 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<ILoggingEvent> 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.
* <p>
* 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<String, Object> 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<String, Object> 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<SpringValue> 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<String, Object> source = new HashMap<>(contentOf(changedKey, changeInfo.getOldValue()));
PolarisPropertySource propertySource = new PolarisPropertySource(file.getNamespace(), file.getFileGroup(),
file.getFileName(), file, source);
refresher.registerPolarisConfigPublishChangeListener(propertySource);
Map<String, ConfigPropertyChangeInfo> 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<String, Object> contentOf(String key, Object value) {
Map<String, Object> content = new HashMap<>();
content.put(key, value);
return content;
}
private ListAppender<ILoggingEvent> attachAppender(Class<?> loggerClass) {
ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(loggerClass);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.setContext(logger.getLoggerContext());
appender.start();
logger.addAppender(appender);
appenders.add(appender);
return appender;
}
private String renderLogs(ListAppender<ILoggingEvent> appender) {
StringBuilder builder = new StringBuilder();
for (ILoggingEvent event : appender.list) {
builder.append(event.getFormattedMessage()).append('\n');
}
return builder.toString();
}
}

@ -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);

@ -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<ConfigKeyConflict> conflicts = provider.resolveConflicts("k", metadata(NAMESPACE, "mygroup", "b.yaml"));

@ -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<String, Object> 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<String, Object> 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<Map<String, Object>> sources = (List<Map<String, Object>>) 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<Map<String, Object>> sources =
(List<Map<String, Object>>) endpoint.polarisConfig().get("PolarisPropertySource");
Map<String, Object> configKVFile = (Map<String, Object>) sources.get(0).get("configKVFile");
List<Map<String, Object>> files =
(List<Map<String, Object>>) configKVFile.get("configKVFiles");
assertThat(files).extracting(file -> file.get("fileName"))
.containsExactly("first.properties", "second.properties");
}
@Test

Loading…
Cancel
Save