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 6075d5f2e..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 @@ -265,9 +265,27 @@ public class ConfigurationModifier implements PolarisConfigurationConfigModifier if (StringUtils.isBlank(rootPath)) { return false; } - // Polaris config cache file names follow namespace#group#fileName.yaml. - File[] files = new File(rootPath).listFiles(file -> file.isFile() - && file.getName().endsWith(".yaml") && file.getName().contains("#")); + 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 7acaba94f..321282a08 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 @@ -185,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); @@ -215,7 +218,8 @@ public abstract class PolarisConfigPropertyAutoRefresher implements ApplicationL // 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()); + markEncryptedKeys(listenPolarisPropertySource.getConfigKVFile(), + configKVFileChangeEvent.getConfigFile(), configKVFileChangeEvent.changedKeys()); Map effectSource = effectPolarisPropertySource.getSource(); Map listenSource = listenPolarisPropertySource.getSource(); @@ -245,6 +249,8 @@ public abstract class PolarisConfigPropertyAutoRefresher implements ApplicationL 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); } @@ -310,11 +316,37 @@ public abstract class PolarisConfigPropertyAutoRefresher implements ApplicationL * 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 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) { - if (kvFile == null || configFile == null || !configFile.isEncrypted()) { + 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(); 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..06bc4633f 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,23 @@ public class SpringConfigEffectiveValueProvider implements ConfigEffectiveValueP } String effectiveValue = null; String propertySource = null; + ConfigFileMetadata sourceFile = null; 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(); + } } 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); } @Override @@ -138,15 +144,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 +163,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 +193,22 @@ 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: the SDK cannot judge encryption, so no coordinate. + return new SourceMatch(source.getName(), null); } /** @@ -275,6 +283,11 @@ 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())); + } + private String formatCoordinate(ConfigKVFile file) { return SOURCE_PREFIX + file.getNamespace() + "/" + file.getFileGroup() + "/" + file.getFileName(); } @@ -285,4 +298,30 @@ public class SpringConfigEffectiveValueProvider implements ConfigEffectiveValueP } return metadata.getNamespace() + "/" + metadata.getFileGroup() + "/" + metadata.getFileName(); } + + /** + * A matched property source: the display identity plus, 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; + + SourceMatch(String name, ConfigFileMetadata file) { + this.name = name; + this.file = file; + } + + String getName() { + return name; + } + + ConfigFileMetadata getFile() { + return file; + } + } } 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 4997486cb..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,10 +82,19 @@ public final class PolarisPropertySourceUtils { } if (LOGGER.isDebugEnabled()) { - // only coordinates and key names: values of encrypted config files must not be logged. - // This method cannot tell whether the group holds encrypted files, so no value is logged at all. - LOGGER.debug("[SCT Config] load group property source. namespace = {}, group = {}, propertyCount = {}, keys = {}", - namespace, group, map.size(), map.keySet()); + // 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/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 99fd4593a..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 @@ -718,7 +718,8 @@ class ConfigurationModifierTest { void testModify_CheckAddressNotAccessibleWithLocalCacheFallback() throws Exception { ConfigurationImpl configuration = buildMockConfiguration(); ConnectorConfigImpl connectorConfig = configuration.getConfigFile().getServerConnector(); - Path cacheFile = Files.createFile(tempDir.resolve("default#group#application.properties.yaml")); + Path cacheFile = Files.writeString(tempDir.resolve("default#group#application.properties.yaml"), + "content: cached"); when(polarisContextProperties.getEnabled()).thenReturn(true); when(polarisConfigProperties.isEnabled()).thenReturn(true); @@ -755,6 +756,72 @@ class ConfigurationModifierTest { } } + /** + * 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 index fd2a615c9..67d4f8bc7 100644 --- 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 @@ -19,10 +19,13 @@ 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; @@ -40,6 +43,8 @@ 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; @@ -210,10 +215,14 @@ public class PolarisConfigSensitiveDataMaskingTest { } /** - * Item 2: a plain config file keeps the original behaviour and logs the raw value. + * 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 testPlainConfigChangeLogKeepsRawValue() { + public void testPlainConfigChangeLogIsNotMasked() { PolarisConfigPropertyAutoRefresher refresher = buildRefresher(); ListAppender appender = attachAppender(PolarisConfigPropertyAutoRefresher.class); @@ -222,7 +231,7 @@ public class PolarisConfigSensitiveDataMaskingTest { new ConfigPropertyChangeInfo("app.name", "old-name", PLAIN_VALUE, ChangeType.MODIFIED)); String logs = renderLogs(appender); - assertThat(logs).contains(PLAIN_VALUE).doesNotContain("***(len="); + assertThat(logs).contains("app.name").contains("MODIFIED").doesNotContain("***(len="); } /** @@ -275,18 +284,40 @@ public class PolarisConfigSensitiveDataMaskingTest { } /** - * Item 7: the group-dimension DEBUG log carries key names only, never values. + * 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 testGroupPropertySourceDebugLogCarriesNoValue() { + 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("db.password", SENSITIVE_VALUE), + 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"); @@ -298,9 +329,7 @@ public class PolarisConfigSensitiveDataMaskingTest { .loadGroupPolarisPropertySource(configFileService, testNamespace, testFileGroup); assertThat(source).isNotNull(); - String logs = renderLogs(appender); - assertThat(logs).doesNotContain(SENSITIVE_VALUE); - assertThat(logs).contains("db.password").contains("propertyCount = 1"); + return renderLogs(appender); } finally { logger.setLevel(originalLevel); @@ -331,6 +360,116 @@ public class PolarisConfigSensitiveDataMaskingTest { 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. @@ -352,8 +491,8 @@ public class PolarisConfigSensitiveDataMaskingTest { when(valueContext.getHost()).thenReturn("mockHost"); 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); 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..71d4765b6 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,8 @@ class SpringConfigEffectiveValueProviderTest { assertThat(value.getFileValue()).isEqualTo("8080"); assertThat(value.getEffectiveValue()).isEqualTo("9090"); assertThat(value.getPropertySource()).isEqualTo("commandLineArgs"); + // not a polaris config file: no coordinate, so the SDK falls back to its conservative path + assertThat(value.getSourceFile()).isNull(); } @Test @@ -140,6 +142,11 @@ 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.getSourceFile()).isNotNull(); + assertThat(value.getSourceFile().getNamespace()).isEqualTo(NAMESPACE); + assertThat(value.getSourceFile().getFileGroup()).isEqualTo(GROUP); + assertThat(value.getSourceFile().getFileName()).isEqualTo(FILE_NAME); } @Test @@ -199,6 +206,10 @@ 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.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"));