From 252b00e895830e3c7632b5157798519d479355a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E5=8D=9A=20=E6=B4=AA?= <2012753288@qq.com> Date: Sun, 8 Mar 2026 19:35:57 +0800 Subject: [PATCH 01/43] optimize: move DBTYPE from core to common --- .../src/main/java/org/apache/seata/core/constants/DBType.java | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {core => common}/src/main/java/org/apache/seata/core/constants/DBType.java (100%) diff --git a/core/src/main/java/org/apache/seata/core/constants/DBType.java b/common/src/main/java/org/apache/seata/core/constants/DBType.java similarity index 100% rename from core/src/main/java/org/apache/seata/core/constants/DBType.java rename to common/src/main/java/org/apache/seata/core/constants/DBType.java From 261303c062359843faf6c870966d967013348d42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E5=8D=9A=20=E6=B4=AA?= <2012753288@qq.com> Date: Sun, 8 Mar 2026 19:39:37 +0800 Subject: [PATCH 02/43] feat: initialize business data source configuration instance --- .../props/BusinessDataSourcesProperties.java | 374 ++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100644 console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java diff --git a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java new file mode 100644 index 00000000000..b43ef211bc0 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java @@ -0,0 +1,374 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.core.props; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seata.common.util.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.EnumerablePropertySource; +import org.springframework.core.env.Environment; +import org.springframework.core.env.PropertySource; +import org.springframework.stereotype.Component; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import static org.apache.seata.common.DefaultValues.DEFAULT_DB_MAX_CONN; +import static org.apache.seata.common.DefaultValues.DEFAULT_DB_MIN_CONN; + +@Component +public class BusinessDataSourcesProperties implements InitializingBean { + + public BusinessDataSourcesProperties(Environment env, ObjectMapper objectMapper) { + this.env = env; + this.objectMapper = objectMapper; + } + + private final Environment env; + + private final ObjectMapper objectMapper; + + private static final Map datasources = new ConcurrentHashMap<>(); + + private static final Map dataSourcesNamesAndResourceIds = new ConcurrentHashMap<>(); + + private static final String BASE_PREFIX = "seata.businessDataSources."; + + private static final Logger LOGGER = LoggerFactory.getLogger(BusinessDataSourcesProperties.class); + + @Override + public void afterPropertiesSet() { + + Set dataSourceNames = getDataSourceNames(); + + for (String name : dataSourceNames) { + DataSourceProperties props = new DataSourceProperties(); + String prefix = BASE_PREFIX + name + "."; + props.setEnabled(env.getProperty(prefix + "enabled", Boolean.class, true)); + props.setDbType(env.getProperty(prefix + "dbType", "mysql")); + props.setDriverClassName(env.getProperty(prefix + "driverClassName", "com.mysql.cj.jdbc.Driver")); + props.setUrl(env.getProperty(prefix + "url")); + props.setUsername(env.getProperty(prefix + "username")); + props.setPassword(env.getProperty(prefix + "password")); + props.setDatasource(env.getProperty(prefix + "datasource", "druid")); + props.setMinConn(env.getProperty(prefix + "minConn", Integer.class, DEFAULT_DB_MIN_CONN)); + if (props.getMinConn() <= 0 || props.getMinConn() > DEFAULT_DB_MIN_CONN) { + LOGGER.warn("The minimum number of connections for a data source: {} is not compliant", name); + continue; + } + props.setMaxConn(env.getProperty(prefix + "maxConn", Integer.class, DEFAULT_DB_MAX_CONN)); + if (props.getMaxConn() <= 0 || props.getMaxConn() > DEFAULT_DB_MAX_CONN) { + LOGGER.warn("The maximum number of connections for a data source: {} is not compliant", name); + continue; + } + props.setMaxWait(env.getProperty(prefix + "maxWait", Long.class, 5000L)); + + if (!validateDataSourceProperties(props, name)) { + continue; + } + + String resourceId = getOriginUrl(props.getUrl()); + + if (props.enabled) { + datasources.put(resourceId, props); + dataSourcesNamesAndResourceIds.put(name, resourceId); + } + } + } + + private boolean validateDataSourceProperties(DataSourceProperties props, String dataSourceName) { + if (props == null) { + LOGGER.error("DataSource configuration cannot be null for: {}", dataSourceName); + return false; + } + + if (!StringUtils.hasText(props.getUrl())) { + LOGGER.error("Database URL cannot be empty for datasource: {}", dataSourceName); + return false; + } + + if (!StringUtils.hasText(props.getUsername())) { + LOGGER.error("Database username cannot be empty for datasource: {}", dataSourceName); + return false; + } + + if (!StringUtils.hasText(props.getPassword())) { + LOGGER.error("Database password cannot be empty for datasource: {}", dataSourceName); + return false; + } + + if (!StringUtils.hasText(props.getDriverClassName())) { + LOGGER.error("Database driver class name cannot be empty for datasource: {}", dataSourceName); + return false; + } + + if (!StringUtils.hasText(props.getDbType())) { + LOGGER.error("Database type cannot be empty for datasource: {}", dataSourceName); + return false; + } + + if (props.getMinConn() < 0) { + LOGGER.error("Minimum connection count cannot be negative for datasource: {}", dataSourceName); + return false; + } + + if (props.getMaxConn() <= 0) { + LOGGER.error("Maximum connection count must be positive for datasource: {}", dataSourceName); + return false; + } + + if (props.getMinConn() > props.getMaxConn()) { + LOGGER.error( + "Minimum connection count cannot be greater than maximum connection count for datasource: {}", + dataSourceName); + return false; + } + + if (props.getMaxWait() != null && props.getMaxWait() < 0) { + LOGGER.error("Maximum wait time cannot be negative for datasource: {}", dataSourceName); + return false; + } + + if (!props.getUrl().toLowerCase().startsWith("jdbc:")) { + LOGGER.error("Invalid JDBC URL format for datasource: {}. URL should start with 'jdbc:'", dataSourceName); + return false; + } + return true; + } + + private DataSourceProperties parseDBPropertyFromJson(JsonNode jsonNode) { + if (jsonNode == null || jsonNode.isEmpty()) { + throw new IllegalArgumentException("JSON configuration cannot be null"); + } + DataSourceProperties props = new DataSourceProperties(); + + props.setDbType(jsonNode.has("dbType") ? jsonNode.get("dbType").asText() : "mysql"); + + String driverClassName = getDefaultDriverClassName(props.getDbType()); + props.setDriverClassName(driverClassName); + + if (!jsonNode.has("url")) { + throw new IllegalArgumentException("The database URL cannot be empty"); + } + props.setUrl(jsonNode.get("url").asText()); + + if (!jsonNode.has("username")) { + throw new IllegalArgumentException("The database username cannot be empty"); + } + props.setUsername(jsonNode.get("username").asText()); + + if (!jsonNode.has("password")) { + throw new IllegalArgumentException("The database password cannot be empty"); + } + props.setPassword(jsonNode.get("password").asText()); + + props.setDatasource( + jsonNode.has("datasource") ? jsonNode.get("datasource").asText() : "druid"); + props.setMinConn(jsonNode.has("minConn") ? jsonNode.get("minConn").asInt() : DEFAULT_DB_MIN_CONN); + props.setMaxConn(jsonNode.has("maxConn") ? jsonNode.get("maxConn").asInt() : DEFAULT_DB_MAX_CONN); + props.setMaxWait(jsonNode.has("maxWait") ? jsonNode.get("maxWait").asLong() : 5000L); + + return props; + } + + public void registerDataSourceFromJson(String jsonConfig) throws Exception { + JsonNode jsonNode = objectMapper.readTree(jsonConfig); + if (jsonNode == null || jsonNode.isEmpty()) { + throw new IllegalArgumentException("JSON configuration cannot be null"); + } + String name = jsonNode.get("dbName").asText(); + if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("The data source name cannot be empty"); + } + + DataSourceProperties props = parseDBPropertyFromJson(jsonNode); + if (!validateDataSourceProperties(props, name)) { + throw new IllegalArgumentException("Business DataSource Properties has failure"); + } + String resourceId = getOriginUrl(props.getUrl()); + + datasources.put(resourceId, props); + dataSourcesNamesAndResourceIds.put(name, resourceId); + } + + private static String getDefaultDriverClassName(String dbType) { + switch (dbType.toLowerCase()) { + case "postgresql": + return "org.postgresql.Driver"; + case "oracle": + return "oracle.jdbc.driver.OracleDriver"; + case "sqlserver": + return "com.microsoft.sqlserver.jdbc.SQLServerDriver"; + case "h2": + return "org.h2.Driver"; + case "mysql": + default: + return "com.mysql.cj.jdbc.Driver"; + } + } + + private String getOriginUrl(String url) { + int index = url.indexOf("?"); + if (index != -1) { + url = url.substring(0, index); + } + return url; + } + + private Set getDataSourceNames() { + Set names = new HashSet<>(); + + if (env instanceof ConfigurableEnvironment) { + ConfigurableEnvironment configEnv = (ConfigurableEnvironment) env; + + Set processedNames = new HashSet<>(); + + for (PropertySource propertySource : configEnv.getPropertySources()) { + if (propertySource instanceof EnumerablePropertySource) { + EnumerablePropertySource enumSource = (EnumerablePropertySource) propertySource; + + for (String propertyName : enumSource.getPropertyNames()) { + if (propertyName.startsWith(BASE_PREFIX)) { + String[] parts = propertyName.split("\\."); + if (parts.length > 3) { + String dsName = parts[2]; + if (!processedNames.contains(dsName)) { + if (env.containsProperty(BASE_PREFIX + dsName + ".url")) { + names.add(dsName); + processedNames.add(dsName); + } + } + } + } + } + } + } + } + return names; + } + + public static Map getDatasources() { + return datasources; + } + + public static Map getDataSourcesNamesAndResourceIds() { + return dataSourcesNamesAndResourceIds; + } + + public static Set getResourceIds() { + return datasources.keySet(); + } + + public static class DataSourceProperties { + private boolean enabled = true; + private String dbType = "mysql"; + private String driverClassName = "com.mysql.cj.jdbc.Driver"; + private String url = ""; + private String username = "mysql"; + private String password = "mysql"; + private String datasource = "druid"; + private int minConn = DEFAULT_DB_MIN_CONN; + private int maxConn = DEFAULT_DB_MAX_CONN; + private Long maxWait = 5000L; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Long getMaxWait() { + return maxWait; + } + + public void setMaxWait(Long maxWait) { + this.maxWait = maxWait; + } + + public String getDbType() { + return dbType; + } + + public void setDbType(String dbType) { + this.dbType = dbType; + } + + public String getDriverClassName() { + return driverClassName; + } + + public void setDriverClassName(String driverClassName) { + this.driverClassName = driverClassName; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getDatasource() { + return datasource; + } + + public void setDatasource(String datasource) { + this.datasource = datasource; + } + + public int getMinConn() { + return minConn; + } + + public void setMinConn(int minConn) { + this.minConn = minConn; + } + + public int getMaxConn() { + return maxConn; + } + + public void setMaxConn(int maxConn) { + this.maxConn = maxConn; + } + } +} From 6116630bc5b36d514fecab70970242e2eeefa501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E5=8D=9A=20=E6=B4=AA?= <2012753288@qq.com> Date: Sun, 8 Mar 2026 19:40:39 +0800 Subject: [PATCH 03/43] feat: Implement multiple types of connection pool instances --- console/pom.xml | 12 + .../mcp/store/DbcpDataSourceProvider.java | 66 +++++ .../mcp/store/DruidDataSourceProvider.java | 65 +++++ .../mcp/store/HikariDataSourceProvider.java | 75 +++++ .../db/AbstractMCPDataSourceProvider.java | 256 ++++++++++++++++++ 5 files changed, 474 insertions(+) create mode 100644 console/src/main/java/org/apache/seata/mcp/store/DbcpDataSourceProvider.java create mode 100644 console/src/main/java/org/apache/seata/mcp/store/DruidDataSourceProvider.java create mode 100644 console/src/main/java/org/apache/seata/mcp/store/HikariDataSourceProvider.java create mode 100644 console/src/main/java/org/apache/seata/mcp/store/db/AbstractMCPDataSourceProvider.java diff --git a/console/pom.xml b/console/pom.xml index ed3e487ce82..f0b01bb30a7 100644 --- a/console/pom.xml +++ b/console/pom.xml @@ -159,6 +159,18 @@ spring-ai-starter-mcp-server-webmvc ${spring-ai.version} + + com.alibaba + druid + + + org.apache.commons + commons-dbcp2 + + + com.zaxxer + HikariCP + diff --git a/console/src/main/java/org/apache/seata/mcp/store/DbcpDataSourceProvider.java b/console/src/main/java/org/apache/seata/mcp/store/DbcpDataSourceProvider.java new file mode 100644 index 00000000000..ade7c11356b --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/store/DbcpDataSourceProvider.java @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.store; + +import org.apache.commons.dbcp2.BasicDataSource; +import org.apache.seata.mcp.store.db.AbstractMCPDataSourceProvider; + +import javax.sql.DataSource; +import java.sql.Connection; + +/** + * The dbcp datasource provider + */ +public class DbcpDataSourceProvider extends AbstractMCPDataSourceProvider { + + @Override + public DataSource doGenerate() { + BasicDataSource ds = new BasicDataSource(); + ds.setDriverClassName(getDriverClassName()); + ds.setDriverClassLoader(getDriverClassLoader()); + ds.setUrl(getUrl()); + ds.setUsername(getUser()); + ds.setPassword(getPassword()); + ds.setInitialSize(getMinConn()); + ds.setMaxTotal(getMaxConn()); + ds.setMinIdle(getMinConn()); + ds.setMaxIdle(getMinConn()); + ds.setMaxWaitMillis(getMaxWait()); + + ds.setMaxConnLifetimeMillis(300000); // Maximum connection lifetime (5 minutes) + ds.setLogExpiredConnections(true); // Log expired connections + ds.setConnectionProperties( + "useUnicode=yes;characterEncoding=utf8;socketTimeout=5000;connectTimeout=500;autoReconnect=true;maxReconnects=3;retriesAllDown=3"); + + ds.setTestOnCreate(true); // Validate connection on creation + ds.setTestOnBorrow(true); // Validate connection on borrow + ds.setTestWhileIdle(true); // Validate idle connections + ds.setValidationQuery(getValidationQuery(getDBType())); + ds.setValidationQueryTimeout(5); // Validation query timeout (seconds) + + ds.setFastFailValidation(true); // Fast validation failure + ds.setDefaultTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); + + ds.setMaxWaitMillis(5000); // Maximum wait time of 5 seconds + ds.setAbandonedUsageTracking(true); // Track connection usage + ds.setRemoveAbandonedOnBorrow(true); // Check for abandoned connections on borrow + ds.setRemoveAbandonedOnMaintenance(true); // Remove abandoned connections during maintenance + ds.setRemoveAbandonedTimeout(60); // Mark as abandoned after 60 seconds + + return ds; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/store/DruidDataSourceProvider.java b/console/src/main/java/org/apache/seata/mcp/store/DruidDataSourceProvider.java new file mode 100644 index 00000000000..1acdba0d905 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/store/DruidDataSourceProvider.java @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.store; + +import com.alibaba.druid.pool.DruidDataSource; +import org.apache.seata.mcp.store.db.AbstractMCPDataSourceProvider; + +import javax.sql.DataSource; +import java.sql.Connection; + +/** + * The druid datasource provider + */ +public class DruidDataSourceProvider extends AbstractMCPDataSourceProvider { + + @Override + public DataSource doGenerate() { + DruidDataSource ds = new DruidDataSource(); + ds.setDriverClassName(getDriverClassName()); + ds.setDriverClassLoader(getDriverClassLoader()); + ds.setUrl(getUrl()); + ds.setUsername(getUser()); + ds.setPassword(getPassword()); + + ds.setInitialSize(getMinConn()); // Initial connection pool size + ds.setMaxActive(getMaxConn()); // Maximum active connections + ds.setMinIdle(getMinConn()); // Minimum idle connections + ds.setMaxWait(getMaxWait()); // Maximum wait time for connection acquisition + + ds.setTestOnBorrow(true); // Test connections when borrowing + ds.setTestOnReturn(false); // Don't test on return for performance + ds.setTestWhileIdle(true); // Test idle connections + ds.setTimeBetweenEvictionRunsMillis(60000); // Run evictor every 60 seconds + ds.setMinEvictableIdleTimeMillis(180000); // Min idle time before eviction (3 minutes) + ds.setMaxEvictableIdleTimeMillis(300000); // Max idle time before eviction (5 minutes) + ds.setValidationQuery(getValidationQuery(getDBType())); + ds.setValidationQueryTimeout(5); // Validation timeout (seconds) + + ds.setConnectionErrorRetryAttempts(3); // Retry 3 times on connection error + ds.setBreakAfterAcquireFailure(true); // Break after all retries fail + + ds.setPoolPreparedStatements(true); // Pool prepared statements for better performance + ds.setMaxPoolPreparedStatementPerConnectionSize(20); + ds.setDefaultAutoCommit(true); + + ds.setUseOracleImplicitCache(false); + ds.setDefaultTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); + + return ds; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/store/HikariDataSourceProvider.java b/console/src/main/java/org/apache/seata/mcp/store/HikariDataSourceProvider.java new file mode 100644 index 00000000000..8bc1902b563 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/store/HikariDataSourceProvider.java @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.store; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import com.zaxxer.hikari.util.IsolationLevel; +import org.apache.seata.mcp.store.db.AbstractMCPDataSourceProvider; + +import javax.sql.DataSource; +import java.util.Properties; + +/** + * The hikari datasource provider + */ +public class HikariDataSourceProvider extends AbstractMCPDataSourceProvider { + + @Override + public DataSource doGenerate() { + Properties properties = new Properties(); + properties.setProperty("dataSource.cachePrepStmts", "true"); // Enable prepared statement caching + properties.setProperty("dataSource.prepStmtCacheSize", "250"); // Number of prepared statements to cache + properties.setProperty("dataSource.prepStmtCacheSqlLimit", "2048"); // Maximum SQL statement length to cache + properties.setProperty("dataSource.useServerPrepStmts", "true"); // Use server-side prepared statements + properties.setProperty("dataSource.useLocalSessionState", "true"); // Track transaction state locally + properties.setProperty( + "dataSource.rewriteBatchedStatements", "true"); // Rewrite batched statements for efficiency + properties.setProperty("dataSource.cacheResultSetMetadata", "true"); // Cache result set metadata + properties.setProperty("dataSource.cacheServerConfiguration", "true"); // Cache server configuration + properties.setProperty("dataSource.elideSetAutoCommits", "true"); // Don't send autocommit if unchanged + properties.setProperty("dataSource.maintainTimeStats", "false"); // Disable time statistics tracking + + HikariConfig config = new HikariConfig(properties); + + config.setDriverClassName(getDriverClassName()); + config.setJdbcUrl(getUrl()); + config.setUsername(getUser()); + config.setPassword(getPassword()); + + config.setMaximumPoolSize(getMaxConn()); // Maximum size of connection pool + config.setMinimumIdle(getMinConn()); // Minimum number of idle connections + config.setIdleTimeout(300000); // Maximum idle time (5 minutes) + + config.setConnectionTimeout(getMaxWait()); // Maximum wait time for connection + config.setInitializationFailTimeout(-1); // No timeout for pool initialization + + config.setConnectionTestQuery(getValidationQuery(getDBType())); // Query to validate connections + config.setValidationTimeout(5000); // Validation timeout (5 seconds) + config.setMaxLifetime(1800000); // Maximum connection lifetime (30 minutes) + config.setKeepaliveTime(60000); // Keepalive interval (60 seconds) + + config.setAutoCommit(true); // Auto-commit connections by default + config.setTransactionIsolation(IsolationLevel.TRANSACTION_READ_COMMITTED.name()); + + properties.setProperty("connectionRetryAttempts", "3"); // Attempt connection 3 times + properties.setProperty("connectionRetryDelay", "1000"); // Wait 1 second between retries + properties.setProperty("connectionTimeoutMs", "5000"); // Connection timeout 5 seconds + + return new HikariDataSource(config); + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/store/db/AbstractMCPDataSourceProvider.java b/console/src/main/java/org/apache/seata/mcp/store/db/AbstractMCPDataSourceProvider.java new file mode 100644 index 00000000000..194acbb1cca --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/store/db/AbstractMCPDataSourceProvider.java @@ -0,0 +1,256 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.store.db; + +import org.apache.seata.common.ConfigurationKeys; +import org.apache.seata.common.exception.ShouldNeverHappenException; +import org.apache.seata.common.exception.StoreException; +import org.apache.seata.common.util.StringUtils; +import org.apache.seata.core.constants.DBType; +import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; + +import javax.sql.DataSource; +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Stream; + +import static org.apache.seata.common.DefaultValues.DEFAULT_DB_MAX_CONN; +import static org.apache.seata.common.DefaultValues.DEFAULT_DB_MIN_CONN; + +public abstract class AbstractMCPDataSourceProvider { + + private String resourceId; + + protected static final Map DATASOURCE_PROPERTIES = + BusinessDataSourcesProperties.getDatasources(); + + private static final String MYSQL_DRIVER_CLASS_NAME = "com.mysql.jdbc.Driver"; + + private static final String MYSQL8_DRIVER_CLASS_NAME = "com.mysql.cj.jdbc.Driver"; + + private static final String MYSQL_DRIVER_FILE_PREFIX = "mysql-connector-j"; + + private static final Map DRIVER_LOADERS; + + private static final long DEFAULT_DB_MAX_WAIT = 5000; + + static { + DRIVER_LOADERS = createMysqlDriverClassLoaders(); + } + + public DataSource generate() { + return doGenerate(); + } + + public DataSource generateByResourceId(String resourceId) { + this.resourceId = resourceId; + return generate(); + } + + public void validate() { + String driverClassName = getDriverClassName(); + ClassLoader loader = getDriverClassLoader(); + if (null == loader) { + throw new StoreException("class loader set error, you should not use the Bootstrap classloader"); + } + try { + loader.loadClass(driverClassName); + } catch (ClassNotFoundException exx) { + String folderPath = System.getProperty("loader.path"); + if (folderPath == null) { + folderPath = System.getProperty("java.class.path"); + } + String driverClassPath = Stream.of(folderPath.split(File.pathSeparator)) + .map(File::new) + .filter(File::exists) + .map(file -> file.isFile() ? file.getParentFile() : file) + .filter(Objects::nonNull) + .filter(File::isDirectory) + .map(file -> (MYSQL8_DRIVER_CLASS_NAME.equals(driverClassName) + || MYSQL_DRIVER_CLASS_NAME.equals(driverClassName)) + ? new File(file, "jdbc") + : file) + .filter(File::exists) + .filter(File::isDirectory) + .distinct() + .findAny() + .map(File::getAbsolutePath) + .orElseThrow(() -> new ShouldNeverHappenException("cannot find jdbc folder")); + throw new StoreException(String.format( + "The driver {%s} cannot be found in the path %s. Please ensure that the appropriate database driver dependencies are included in the classpath.", + driverClassName, driverClassPath)); + } + } + + public abstract DataSource doGenerate(); + + protected DBType getDBType() { + BusinessDataSourcesProperties.DataSourceProperties properties = getDataSourceProperties(); + if (properties != null) { + return DBType.valueof(properties.getDbType()); + } + return null; + } + + protected BusinessDataSourcesProperties.DataSourceProperties getDataSourceProperties() { + if (StringUtils.isBlank(resourceId)) { + if (DATASOURCE_PROPERTIES.size() == 1) { + return DATASOURCE_PROPERTIES.values().iterator().next(); + } + throw new StoreException("resourceId is not specified and there are multiple datasource properties"); + } + return DATASOURCE_PROPERTIES.get(resourceId); + } + + protected String getDriverClassName() { + BusinessDataSourcesProperties.DataSourceProperties properties = getDataSourceProperties(); + String driverClassName = ""; + if (properties != null) { + driverClassName = properties.getDriverClassName(); + if (StringUtils.isBlank(driverClassName)) { + throw new StoreException( + String.format("the {%s} can't be empty", ConfigurationKeys.STORE_DB_DRIVER_CLASS_NAME)); + } + } + return driverClassName; + } + + protected Long getMaxWait() { + BusinessDataSourcesProperties.DataSourceProperties properties = getDataSourceProperties(); + if (properties != null && properties.getMaxWait() != null) { + return properties.getMaxWait(); + } + return DEFAULT_DB_MAX_WAIT; + } + + protected ClassLoader getDriverClassLoader() { + return DRIVER_LOADERS.getOrDefault(getDriverClassName(), this.getClass().getClassLoader()); + } + + private static Map createMysqlDriverClassLoaders() { + Map loaders = new HashMap<>(); + String cp = System.getProperty("loader.path"); + if (cp == null) { + cp = System.getProperty("java.class.path"); + } + if (cp == null || cp.isEmpty()) { + return loaders; + } + Stream.of(cp.split(File.pathSeparator)) + .map(File::new) + .filter(File::exists) + .map(file -> file.isFile() ? file.getParentFile() : file) + .filter(Objects::nonNull) + .filter(File::isDirectory) + .map(file -> new File(file, "jdbc")) + .filter(File::exists) + .filter(File::isDirectory) + .distinct() + .flatMap(file -> { + File[] files = file.listFiles((f, name) -> name.startsWith(MYSQL_DRIVER_FILE_PREFIX)); + if (files != null) { + return Stream.of(files); + } else { + return Stream.of(); + } + }) + .forEach(file -> { + if (loaders.containsKey(MYSQL8_DRIVER_CLASS_NAME) && loaders.containsKey(MYSQL_DRIVER_CLASS_NAME)) { + return; + } + try { + URL url = file.toURI().toURL(); + ClassLoader loader = new URLClassLoader(new URL[] {url}, ClassLoader.getSystemClassLoader()); + try { + loader.loadClass(MYSQL8_DRIVER_CLASS_NAME); + loaders.putIfAbsent(MYSQL8_DRIVER_CLASS_NAME, loader); + } catch (ClassNotFoundException e) { + loaders.putIfAbsent(MYSQL_DRIVER_CLASS_NAME, loader); + } + } catch (MalformedURLException ignore) { + } + }); + return loaders; + } + + protected String getUrl() { + BusinessDataSourcesProperties.DataSourceProperties properties = getDataSourceProperties(); + String url = ""; + if (properties != null) { + url = properties.getUrl(); + if (StringUtils.isBlank(url)) { + throw new StoreException(String.format("the {%s} can't be empty", ConfigurationKeys.STORE_DB_URL)); + } + } + return url; + } + + protected String getUser() { + BusinessDataSourcesProperties.DataSourceProperties properties = getDataSourceProperties(); + String username = ""; + if (properties != null) { + username = properties.getUsername(); + if (StringUtils.isBlank(username)) { + throw new StoreException(String.format("the {%s} can't be empty", ConfigurationKeys.STORE_DB_USER)); + } + } + return username; + } + + protected String getPassword() { + BusinessDataSourcesProperties.DataSourceProperties properties = getDataSourceProperties(); + String password = ""; + if (properties != null) { + password = properties.getPassword(); + if (StringUtils.isBlank(password)) { + throw new StoreException(String.format("the {%s} can't be empty", ConfigurationKeys.STORE_DB_PASSWORD)); + } + } + return password; + } + + protected int getMinConn() { + BusinessDataSourcesProperties.DataSourceProperties properties = getDataSourceProperties(); + int minConn = -1; + if (properties != null) { + minConn = properties.getMinConn(); + } + return minConn < 0 ? DEFAULT_DB_MIN_CONN : minConn; + } + + protected int getMaxConn() { + BusinessDataSourcesProperties.DataSourceProperties properties = getDataSourceProperties(); + int maxConn = -1; + if (properties != null) { + maxConn = properties.getMaxConn(); + } + return maxConn < 0 ? DEFAULT_DB_MAX_CONN : maxConn; + } + + protected String getValidationQuery(DBType dbType) { + if (DBType.ORACLE.equals(dbType)) { + return "select sysdate from dual"; + } else { + return "select 1"; + } + } +} From 6de412dc587b71d04cfb91780b007a20765a2132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E5=8D=9A=20=E6=B4=AA?= <2012753288@qq.com> Date: Sun, 8 Mar 2026 19:41:20 +0800 Subject: [PATCH 04/43] feat: Implement the functions of initializing the business data source factory and requesting header data source configuration --- .../console/config/WebSecurityConfig.java | 8 ++ .../filter/MCPBusinessDataSourceFilter.java | 68 ++++++++++++++ .../seata/mcp/store/DataSourceFactory.java | 88 +++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java create mode 100644 console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java diff --git a/console/src/main/java/org/apache/seata/console/config/WebSecurityConfig.java b/console/src/main/java/org/apache/seata/console/config/WebSecurityConfig.java index dc773eeabb0..460564adfa2 100644 --- a/console/src/main/java/org/apache/seata/console/config/WebSecurityConfig.java +++ b/console/src/main/java/org/apache/seata/console/config/WebSecurityConfig.java @@ -18,9 +18,11 @@ import org.apache.seata.common.util.StringUtils; import org.apache.seata.console.filter.JwtAuthenticationTokenFilter; +import org.apache.seata.console.filter.MCPBusinessDataSourceFilter; import org.apache.seata.console.security.CustomUserDetailsServiceImpl; import org.apache.seata.console.security.JwtAuthenticationEntryPoint; import org.apache.seata.console.utils.JwtTokenUtils; +import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; import org.apache.seata.mcp.core.props.MCPProperties; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -86,6 +88,9 @@ public class WebSecurityConfig { @Autowired private MCPProperties mcpProperties; + @Autowired + private BusinessDataSourcesProperties businessDataSourcesProperties; + @Value("${seata.security.ignore.urls:/**}") String ignoreURLs; @@ -136,6 +141,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, Authentication }) .addFilterBefore( new JwtAuthenticationTokenFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore( + new MCPBusinessDataSourceFilter(businessDataSourcesProperties), JwtAuthenticationTokenFilter.class + ) .headers(headers -> headers.cacheControl(cache -> {})); return http.build(); diff --git a/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java b/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java new file mode 100644 index 00000000000..35ca5ff590e --- /dev/null +++ b/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.console.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; +import org.springframework.http.HttpStatus; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public class MCPBusinessDataSourceFilter extends OncePerRequestFilter { + + private final BusinessDataSourcesProperties businessDataSourcesProperties; + + private final Set processedConfigs = ConcurrentHashMap.newKeySet(); + + public MCPBusinessDataSourceFilter(BusinessDataSourcesProperties properties) { + this.businessDataSourcesProperties = properties; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String combinedHeader = request.getHeader("X-DB-Config"); + if (combinedHeader != null && !combinedHeader.isEmpty()) { + String[] jsonConfigs = combinedHeader.split(";"); + for (String jsonDBConfig : jsonConfigs) { + if (processedConfigs.contains(jsonDBConfig.trim())) { + continue; + } + try { + businessDataSourcesProperties.registerDataSourceFromJson(jsonDBConfig.trim()); + processedConfigs.add(jsonDBConfig.trim()); + } catch (Exception e) { + if (!response.isCommitted()) { + response.sendError( + HttpStatus.BAD_REQUEST.value(), + "The business database parameter in the request header is incorrect: " + + e.getMessage()); + return; + } + } + } + } + filterChain.doFilter(request, response); + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java b/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java new file mode 100644 index 00000000000..9090542ee2c --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.store; + +import jakarta.annotation.PostConstruct; +import org.apache.seata.common.exception.StoreException; +import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class DataSourceFactory { + + private static final Map dataSourceMap = new ConcurrentHashMap<>(); + + private static final Logger LOGGER = LoggerFactory.getLogger(DataSourceFactory.class); + + @PostConstruct + public void init() { + DataSourceFactory.initAllDataSources(); + } + + public static void initAllDataSources() { + Map datasources = + BusinessDataSourcesProperties.getDatasources(); + datasources.forEach( + (resourceId, props) -> dataSourceMap.computeIfAbsent(resourceId, key -> createDataSource(props, key))); + } + + public static DataSource getDataSource(String resourceId) { + return dataSourceMap.computeIfAbsent(resourceId, key -> { + BusinessDataSourcesProperties.DataSourceProperties props = + BusinessDataSourcesProperties.getDatasources().get(key); + if (props == null) { + throw new StoreException("Cannot find datasource properties: " + key); + } + return createDataSource(props, key); + }); + } + + public static void removeErrorDataSource(String resourceId, Exception e) { + dataSourceMap.remove(resourceId); + LOGGER.info("Delete Business DataSource, resourceId: {}", resourceId); + throw new StoreException( + "The Business DataSource: " + resourceId + " can't be connected due to: " + e.getMessage()); + } + + public static DataSource createDataSource( + BusinessDataSourcesProperties.DataSourceProperties dataSourceProperties, String resourceId) { + if (dataSourceProperties == null) { + throw new StoreException("Cannot find datasource properties:" + dataSourceProperties); + } + + String type = dataSourceProperties.getDatasource(); + switch (type) { + case "druid": + DruidDataSourceProvider druidDataSourceProvider = new DruidDataSourceProvider(); + return druidDataSourceProvider.generateByResourceId(resourceId); + case "hikari": + HikariDataSourceProvider hikariDataSourceProvider = new HikariDataSourceProvider(); + return hikariDataSourceProvider.generateByResourceId(resourceId); + case "dbcp": + DbcpDataSourceProvider dbcpDataSourceProvider = new DbcpDataSourceProvider(); + return dbcpDataSourceProvider.generateByResourceId(resourceId); + default: + throw new StoreException("Unknown datasource type:" + type); + } + } +} From fd5a40210503660b821af5ede7c8e62233ccbd18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E5=8D=9A=20=E6=B4=AA?= <2012753288@qq.com> Date: Sun, 8 Mar 2026 20:03:35 +0800 Subject: [PATCH 05/43] feat: Implement the underlying SQL statement execution function --- .../seata/mcp/store/SqlExecutionTemplate.java | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 console/src/main/java/org/apache/seata/mcp/store/SqlExecutionTemplate.java diff --git a/console/src/main/java/org/apache/seata/mcp/store/SqlExecutionTemplate.java b/console/src/main/java/org/apache/seata/mcp/store/SqlExecutionTemplate.java new file mode 100644 index 00000000000..225f8aac536 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/store/SqlExecutionTemplate.java @@ -0,0 +1,154 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.store; + +import org.apache.seata.common.exception.StoreException; +import org.apache.seata.common.util.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +@Service +public class SqlExecutionTemplate { + + private static final Pattern SELECT_PATTERN = + Pattern.compile("^\\s*SELECT\\b.*", Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + + private static final Logger LOGGER = LoggerFactory.getLogger(SqlExecutionTemplate.class); + + private DataSource getDataSource(String resourceId) { + try { + return DataSourceFactory.getDataSource(resourceId); + } catch (Exception e) { + LOGGER.error("Failed to get the data source, resourceId: {}", resourceId, e); + throw new StoreException("Unable to get the data source: " + resourceId); + } + } + + private boolean validateQuerySql(String sql) { + if (sql == null || StringUtils.isBlank(sql)) { + return false; + } + return SELECT_PATTERN.matcher(sql).matches(); + } + + public List> query(String resourceId, String sql, Object... params) { + Connection conn = null; + PreparedStatement ps = null; + ResultSet rs = null; + + try { + if (!validateQuerySql(sql)) { + throw new StoreException("The query valid failed,Only query operations are allowed:" + sql); + } + conn = getConnection(resourceId); + if (params == null || params.length == 0) { + if ((sql.contains("where") || sql.contains("WHERE"))) { + throw new StoreException( + "Query contains WHERE clause but no parameters were provided. This may lead to unintended full table scans and is not allowed."); + } + } + ps = conn.prepareStatement(sql); + if (params != null) { + for (int i = 0; i < params.length; i++) { + ps.setObject(i + 1, params[i]); + } + } + + rs = ps.executeQuery(); + ResultSetMetaData metaData = rs.getMetaData(); + int columnCount = metaData.getColumnCount(); + + List> results = new ArrayList<>(); + while (rs.next()) { + Map row = new HashMap<>(); + for (int i = 1; i <= columnCount; i++) { + String columnName = metaData.getColumnLabel(i); + Object value = rs.getObject(i); + row.put(columnName, value); + } + results.add(row); + } + + return results; + } catch (SQLException e) { + LOGGER.error("The query failed, resourceId: {}, sql: {}", resourceId, sql, e); + throw new StoreException("The query execution failed: " + e.getMessage()); + } finally { + LOGGER.info("User query business datasource with sql: {}", sql); + closeResources(rs, ps, conn); + } + } + + public Map queryForObject(String resourceId, String sql, Object... params) { + List> results = query(resourceId, sql, params); + return results.isEmpty() ? null : results.get(0); + } + + private void closeResources(ResultSet rs, Statement stmt, Connection conn) { + if (rs != null) { + try { + rs.close(); + } catch (SQLException e) { + LOGGER.warn("fail to close ResultSet", e); + } + } + + if (stmt != null) { + try { + stmt.close(); + } catch (SQLException e) { + LOGGER.warn("fail to close Statement", e); + } + } + + closeConnection(conn); + } + + private void closeConnection(Connection conn) { + if (conn != null) { + try { + conn.close(); + } catch (SQLException e) { + LOGGER.warn("fail to close Connection", e); + } + } + } + + private Connection getConnection(String resourceId) { + try { + return getDataSource(resourceId).getConnection(); + } catch (Exception e) { + LOGGER.error("Get The Business DataSource Connection: {} failed due to: {}", resourceId, e.getMessage()); + DataSourceFactory.removeErrorDataSource(resourceId, e); + throw new StoreException(e); + } + } +} From 29d6d7face843ca7522c9b1367ec159926888b40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E5=8D=9A=20=E6=B4=AA?= <2012753288@qq.com> Date: Sun, 8 Mar 2026 20:06:18 +0800 Subject: [PATCH 06/43] feat: Implement the business data source query function --- .../seata/mcp/core/constant/SqlConstant.java | 27 ++++++ .../service/BusinessDataSourceService.java | 28 ++++++ .../impl/BusinessDataSourceServiceImpl.java | 86 +++++++++++++++++++ .../mcp/tools/BusinessDataSourceTools.java | 80 +++++++++++++++++ 4 files changed, 221 insertions(+) create mode 100644 console/src/main/java/org/apache/seata/mcp/core/constant/SqlConstant.java create mode 100644 console/src/main/java/org/apache/seata/mcp/service/BusinessDataSourceService.java create mode 100644 console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java create mode 100644 console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java diff --git a/console/src/main/java/org/apache/seata/mcp/core/constant/SqlConstant.java b/console/src/main/java/org/apache/seata/mcp/core/constant/SqlConstant.java new file mode 100644 index 00000000000..57c559d076a --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/core/constant/SqlConstant.java @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.core.constant; + +public class SqlConstant { + + public static final String GET_TABLE_NAME_SQL = + "SELECT TABLE_NAME, TABLE_COMMENT FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? "; + + public static final String GET_SCHEMA_SQL = + "SELECT COLUMN_NAME, DATA_TYPE, COLUMN_COMMENT FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?"; +} diff --git a/console/src/main/java/org/apache/seata/mcp/service/BusinessDataSourceService.java b/console/src/main/java/org/apache/seata/mcp/service/BusinessDataSourceService.java new file mode 100644 index 00000000000..e30e3a6fd23 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/service/BusinessDataSourceService.java @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.service; + +import java.util.List; +import java.util.Map; + +public interface BusinessDataSourceService { + List getTableNamesBySchema(String resourceId); + + List> getTableSchemaByTableName(String resourceId, String tableName); + + List> runSql(String sql, String resourceId); +} diff --git a/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java b/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java new file mode 100644 index 00000000000..b5bf48d748e --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.service.impl; + +import org.apache.seata.common.exception.StoreException; +import org.apache.seata.common.util.StringUtils; +import org.apache.seata.mcp.core.constant.SqlConstant; +import org.apache.seata.mcp.service.BusinessDataSourceService; +import org.apache.seata.mcp.store.SqlExecutionTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class BusinessDataSourceServiceImpl implements BusinessDataSourceService { + + private final SqlExecutionTemplate sqlExecutionTemplate; + + public BusinessDataSourceServiceImpl(SqlExecutionTemplate sqlExecutionTemplate) { + this.sqlExecutionTemplate = sqlExecutionTemplate; + } + + @Override + public List getTableNamesBySchema(String resourceId) { + String schema = getSchemaNameByResourceId(resourceId); + if (StringUtils.isBlank(schema)) { + throw new StoreException("failed to get schema by resourceId: " + resourceId); + } else { + List> maps = + sqlExecutionTemplate.query(resourceId, SqlConstant.GET_TABLE_NAME_SQL, schema); + return maps.stream() + .map(map -> { + String tableName = String.valueOf(map.get("TABLE_NAME")); + String tableComment = String.valueOf(map.get("TABLE_COMMENT")); + return tableName + " (" + tableComment + ")"; + }) + .collect(Collectors.toList()); + } + } + + @Override + public List> getTableSchemaByTableName(String resourceId, String tableName) { + String schema = getSchemaNameByResourceId(resourceId); + if (StringUtils.isBlank(schema)) { + throw new StoreException("failed to get schema by resourceId: " + resourceId); + } else { + return sqlExecutionTemplate.query(resourceId, SqlConstant.GET_SCHEMA_SQL, schema, tableName); + } + } + + @Override + public List> runSql(String sql, String resourceId) { + if (sql.contains("undo_log")) { + throw new StoreException( + "If you do not use SQL to query undo_log data, use analyzeUndoLog to query and analyze undo_log"); + } + return sqlExecutionTemplate.query(resourceId, sql); + } + + public String getSchemaNameByResourceId(String resourceId) { + if (StringUtils.isBlank(resourceId)) { + return ""; + } + int idx = resourceId.lastIndexOf("/"); + if (idx != -1 && idx != resourceId.length() - 1) { + return resourceId.substring(idx + 1); + } + return ""; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java b/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java new file mode 100644 index 00000000000..37b7f2b33ab --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java @@ -0,0 +1,80 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.tools; + +import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; +import org.apache.seata.mcp.service.BusinessDataSourceService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +@Service +public class BusinessDataSourceTools { + + private final BusinessDataSourceService dataSourceService; + + public BusinessDataSourceTools(BusinessDataSourceService dataSourceService) { + this.dataSourceService = dataSourceService; + } + + private static final Logger LOGGER = LoggerFactory.getLogger(BusinessDataSourceTools.class); + + @McpTool( + description = + "Get the identity and name of the business data source. Important!!!: key is name, value is resourceId") + public Map getResourceIds() { + LOGGER.info("User try to get resource ids"); + return BusinessDataSourcesProperties.getDataSourcesNamesAndResourceIds(); + } + + @McpTool(description = "Get all available table names") + public List getTableNames( + @McpToolParam(description = "The identity of the data source, start with jdbc://", required = true) + String resourceId) { + LOGGER.info("User try to get all table names, resource id {}", resourceId); + return dataSourceService.getTableNamesBySchema(resourceId); + } + + @McpTool(description = "Obtained by table nameSchema") + public List> getTableSchema( + @McpToolParam(description = "Table Name", required = true) String tableName, + @McpToolParam(description = "The identity of the data source, start with jdbc://", required = true) + String resourceId) { + LOGGER.info("User try to get table schema, tableName: {}, resourceId: {}", tableName, resourceId); + return dataSourceService.getTableSchemaByTableName(resourceId, tableName); + } + + @McpTool(description = "Execute the SQL query result, It can only be used to query business data!!!") + public List> runSql( + @McpToolParam(description = "SQL statement, String type", required = true) String sql, + @McpToolParam(description = "The identity of the data source, start with jdbc://", required = true) + String resourceId) { + LOGGER.info("User try to run sql: {}, resourceId: {}", sql, resourceId); + List> result = dataSourceService.runSql(sql, resourceId); + result.add( + Collections.singletonMap( + "Important!!!", + "If it is related to data analysis, statistics, etc., Please generate a table and attach an analysis statement to the user for viewing")); + return result; + } +} From db15f47354f8875ce2cb4fbe4d12b41c4ffbb017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E5=8D=9A=20=E6=B4=AA?= <2012753288@qq.com> Date: Sun, 8 Mar 2026 20:20:14 +0800 Subject: [PATCH 07/43] optimize: spotless check --- .../org/apache/seata/console/config/WebSecurityConfig.java | 4 ++-- .../seata/console/filter/MCPBusinessDataSourceFilter.java | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/console/src/main/java/org/apache/seata/console/config/WebSecurityConfig.java b/console/src/main/java/org/apache/seata/console/config/WebSecurityConfig.java index 460564adfa2..54dc09c36e8 100644 --- a/console/src/main/java/org/apache/seata/console/config/WebSecurityConfig.java +++ b/console/src/main/java/org/apache/seata/console/config/WebSecurityConfig.java @@ -142,8 +142,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, Authentication .addFilterBefore( new JwtAuthenticationTokenFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class) .addFilterBefore( - new MCPBusinessDataSourceFilter(businessDataSourcesProperties), JwtAuthenticationTokenFilter.class - ) + new MCPBusinessDataSourceFilter(businessDataSourcesProperties), + JwtAuthenticationTokenFilter.class) .headers(headers -> headers.cacheControl(cache -> {})); return http.build(); diff --git a/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java b/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java index 35ca5ff590e..17d4d84f2a3 100644 --- a/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java +++ b/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java @@ -39,8 +39,7 @@ public MCPBusinessDataSourceFilter(BusinessDataSourcesProperties properties) { } @Override - protected void doFilterInternal( - HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String combinedHeader = request.getHeader("X-DB-Config"); if (combinedHeader != null && !combinedHeader.isEmpty()) { From a94062460c4573d63ec291fefd97e5fb31155e70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E5=8D=9A=20=E6=B4=AA?= <2012753288@qq.com> Date: Sun, 8 Mar 2026 20:50:35 +0800 Subject: [PATCH 08/43] optimize: Add data source configuration parameters --- .../src/main/resources/application.yml | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/namingserver/src/main/resources/application.yml b/namingserver/src/main/resources/application.yml index 892bfe4b660..bc5310e7cd4 100644 --- a/namingserver/src/main/resources/application.yml +++ b/namingserver/src/main/resources/application.yml @@ -60,6 +60,39 @@ seata: csrf-ignore-urls: /naming/v1/**,/api/v1/naming/** ignore: urls: /,/**/*.css,/**/*.js,/**/*.html,/**/*.map,/**/*.svg,/**/*.png,/**/*.jpeg,/**/*.ico,/api/v1/auth/login,/version.json,/naming/v1/health,/error + + # MCP Server Configuration + mcp: + # Maximum query time interval, The unit is milliseconds, Default one day + query: + max-query-duration: 86400000 + + # Business data source configuration + businessDataSources: + dataSource1: + # Whether this data source is enabled + enabled: true + dbType: mysql + driverClassName: com.mysql.cj.jdbc.Driver # Currently, only mysql is supported + url: + username: root + password: Il02mayi + datasource: druid # Optional connection pool type (druid/hikari/dbcp) + minConn: 5 + maxConn: 50 + maxWait: 5000 + dataSource2: + enabled: true + dbType: mysql + driverClassName: com.mysql.cj.jdbc.Driver + url: + username: root + password: root + datasource: dbcp + minConn: 5 + maxConn: 50 + maxWait: 5000 + management: endpoints: web: @@ -71,8 +104,3 @@ management: distribution: percentiles: http.server.requests: 0.5, 0.99, 0.999 - # MCP Server Configuration - mcp: - # Maximum query time interval, The unit is milliseconds, Default one day - query: - max-query-duration: 86400000 From 0691fbb80e3fdaa80a5674fcc9d02a5e18d43102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E5=8D=9A=20=E6=B4=AA?= <3123004787@mail2.gdut.edu.cn> Date: Mon, 30 Mar 2026 19:50:56 +0800 Subject: [PATCH 09/43] feature: introduce MySQL dependencies in NamingServer --- namingserver/pom.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/namingserver/pom.xml b/namingserver/pom.xml index 7ac5478c8f8..c7470d67ff4 100644 --- a/namingserver/pom.xml +++ b/namingserver/pom.xml @@ -36,6 +36,7 @@ 6.2.8 2.0 11.0.12 + 8.0.27 @@ -173,6 +174,11 @@ com.squareup.okhttp3 okhttp + + mysql + mysql-connector-java + ${mysql8.version} + From aec66ae44523e5084e4a5eecfa408f38582874ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E5=8D=9A=20=E6=B4=AA?= <3123004787@mail2.gdut.edu.cn> Date: Mon, 30 Mar 2026 20:00:51 +0800 Subject: [PATCH 10/43] optimize: spotless check --- .../org/apache/seata/mcp/tools/BusinessDataSourceTools.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java b/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java index 37b7f2b33ab..1c19fe124ea 100644 --- a/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java +++ b/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java @@ -57,7 +57,7 @@ public List getTableNames( @McpTool(description = "Obtained by table nameSchema") public List> getTableSchema( - @McpToolParam(description = "Table Name", required = true) String tableName, + @McpToolParam(description = "Table Name") String tableName, @McpToolParam(description = "The identity of the data source, start with jdbc://", required = true) String resourceId) { LOGGER.info("User try to get table schema, tableName: {}, resourceId: {}", tableName, resourceId); @@ -66,7 +66,7 @@ public List> getTableSchema( @McpTool(description = "Execute the SQL query result, It can only be used to query business data!!!") public List> runSql( - @McpToolParam(description = "SQL statement, String type", required = true) String sql, + @McpToolParam(description = "SQL statement, String type") String sql, @McpToolParam(description = "The identity of the data source, start with jdbc://", required = true) String resourceId) { LOGGER.info("User try to run sql: {}, resourceId: {}", sql, resourceId); From 356a4b098604710e14b866ad1609bc67556b96e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Wed, 3 Jun 2026 22:38:41 +0800 Subject: [PATCH 11/43] fix: secure MCP data source filter --- .../console/config/WebSecurityConfig.java | 4 +-- .../filter/MCPBusinessDataSourceFilter.java | 28 ++++++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/console/src/main/java/org/apache/seata/console/config/WebSecurityConfig.java b/console/src/main/java/org/apache/seata/console/config/WebSecurityConfig.java index 54dc09c36e8..2f484355134 100644 --- a/console/src/main/java/org/apache/seata/console/config/WebSecurityConfig.java +++ b/console/src/main/java/org/apache/seata/console/config/WebSecurityConfig.java @@ -141,8 +141,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, Authentication }) .addFilterBefore( new JwtAuthenticationTokenFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class) - .addFilterBefore( - new MCPBusinessDataSourceFilter(businessDataSourcesProperties), + .addFilterAfter( + new MCPBusinessDataSourceFilter(businessDataSourcesProperties, mcpEndpoints), JwtAuthenticationTokenFilter.class) .headers(headers -> headers.cacheControl(cache -> {})); diff --git a/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java b/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java index 17d4d84f2a3..c9682d4fcca 100644 --- a/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java +++ b/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java @@ -22,20 +22,35 @@ import jakarta.servlet.http.HttpServletResponse; import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.List; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; public class MCPBusinessDataSourceFilter extends OncePerRequestFilter { private final BusinessDataSourcesProperties businessDataSourcesProperties; + private final List mcpEndpointMatchers; + private final Set processedConfigs = ConcurrentHashMap.newKeySet(); - public MCPBusinessDataSourceFilter(BusinessDataSourcesProperties properties) { + public MCPBusinessDataSourceFilter(BusinessDataSourcesProperties properties, List mcpEndpoints) { this.businessDataSourcesProperties = properties; + this.mcpEndpointMatchers = mcpEndpoints.stream().map(AntPathRequestMatcher::new).collect(Collectors.toList()); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return mcpEndpointMatchers.stream().noneMatch(matcher -> matcher.matches(request)); } @Override @@ -43,6 +58,10 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse throws ServletException, IOException { String combinedHeader = request.getHeader("X-DB-Config"); if (combinedHeader != null && !combinedHeader.isEmpty()) { + if (!isAuthenticated()) { + response.sendError(HttpStatus.UNAUTHORIZED.value(), "Authentication is required"); + return; + } String[] jsonConfigs = combinedHeader.split(";"); for (String jsonDBConfig : jsonConfigs) { if (processedConfigs.contains(jsonDBConfig.trim())) { @@ -64,4 +83,11 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } filterChain.doFilter(request, response); } + + private boolean isAuthenticated() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return authentication != null + && authentication.isAuthenticated() + && !(authentication instanceof AnonymousAuthenticationToken); + } } From fefaaba9bccd2b931cff060909fc67f529a15805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Wed, 3 Jun 2026 22:39:47 +0800 Subject: [PATCH 12/43] fix: stop MCP filter after invalid config --- .../seata/console/filter/MCPBusinessDataSourceFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java b/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java index c9682d4fcca..9de68757dd6 100644 --- a/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java +++ b/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java @@ -76,8 +76,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse HttpStatus.BAD_REQUEST.value(), "The business database parameter in the request header is incorrect: " + e.getMessage()); - return; } + return; } } } From aed31d89a27ec9c1db5d187190a642a683e1201a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Wed, 3 Jun 2026 22:42:06 +0800 Subject: [PATCH 13/43] fix: bound dynamic MCP data sources --- .../filter/MCPBusinessDataSourceFilter.java | 8 ----- .../props/BusinessDataSourcesProperties.java | 34 +++++++++++++++---- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java b/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java index 9de68757dd6..d26b45a1b76 100644 --- a/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java +++ b/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java @@ -31,8 +31,6 @@ import java.io.IOException; import java.util.List; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; public class MCPBusinessDataSourceFilter extends OncePerRequestFilter { @@ -41,8 +39,6 @@ public class MCPBusinessDataSourceFilter extends OncePerRequestFilter { private final List mcpEndpointMatchers; - private final Set processedConfigs = ConcurrentHashMap.newKeySet(); - public MCPBusinessDataSourceFilter(BusinessDataSourcesProperties properties, List mcpEndpoints) { this.businessDataSourcesProperties = properties; this.mcpEndpointMatchers = mcpEndpoints.stream().map(AntPathRequestMatcher::new).collect(Collectors.toList()); @@ -64,12 +60,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } String[] jsonConfigs = combinedHeader.split(";"); for (String jsonDBConfig : jsonConfigs) { - if (processedConfigs.contains(jsonDBConfig.trim())) { - continue; - } try { businessDataSourcesProperties.registerDataSourceFromJson(jsonDBConfig.trim()); - processedConfigs.add(jsonDBConfig.trim()); } catch (Exception e) { if (!response.isCommitted()) { response.sendError( diff --git a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java index b43ef211bc0..1b5d1993a21 100644 --- a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java +++ b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java @@ -39,23 +39,32 @@ @Component public class BusinessDataSourcesProperties implements InitializingBean { - public BusinessDataSourcesProperties(Environment env, ObjectMapper objectMapper) { - this.env = env; - this.objectMapper = objectMapper; - } - private final Environment env; private final ObjectMapper objectMapper; + private final int maxDynamicDataSources; + private static final Map datasources = new ConcurrentHashMap<>(); private static final Map dataSourcesNamesAndResourceIds = new ConcurrentHashMap<>(); + private static final Set dynamicResourceIds = ConcurrentHashMap.newKeySet(); + private static final String BASE_PREFIX = "seata.businessDataSources."; + private static final int DEFAULT_MAX_DYNAMIC_DATA_SOURCES = 100; + private static final Logger LOGGER = LoggerFactory.getLogger(BusinessDataSourcesProperties.class); + public BusinessDataSourcesProperties(Environment env, ObjectMapper objectMapper) { + this.env = env; + this.objectMapper = objectMapper; + this.maxDynamicDataSources = + env.getProperty("seata.businessDataSources.max-dynamic-size", Integer.class, + DEFAULT_MAX_DYNAMIC_DATA_SOURCES); + } + @Override public void afterPropertiesSet() { @@ -191,7 +200,7 @@ private DataSourceProperties parseDBPropertyFromJson(JsonNode jsonNode) { return props; } - public void registerDataSourceFromJson(String jsonConfig) throws Exception { + public synchronized void registerDataSourceFromJson(String jsonConfig) throws Exception { JsonNode jsonNode = objectMapper.readTree(jsonConfig); if (jsonNode == null || jsonNode.isEmpty()) { throw new IllegalArgumentException("JSON configuration cannot be null"); @@ -206,8 +215,21 @@ public void registerDataSourceFromJson(String jsonConfig) throws Exception { throw new IllegalArgumentException("Business DataSource Properties has failure"); } String resourceId = getOriginUrl(props.getUrl()); + String existingResourceId = dataSourcesNamesAndResourceIds.get(name); + if (StringUtils.hasText(existingResourceId) && !existingResourceId.equals(resourceId)) { + throw new IllegalArgumentException("The data source name has already been registered: " + name); + } + if (datasources.containsKey(resourceId)) { + dataSourcesNamesAndResourceIds.putIfAbsent(name, resourceId); + return; + } + if (dynamicResourceIds.size() >= maxDynamicDataSources) { + throw new IllegalArgumentException( + "The number of dynamic business data sources exceeds the limit: " + maxDynamicDataSources); + } datasources.put(resourceId, props); + dynamicResourceIds.add(resourceId); dataSourcesNamesAndResourceIds.put(name, resourceId); } From 1547c2b4206cad8c1db5696da635818fd73f862c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Wed, 3 Jun 2026 22:42:57 +0800 Subject: [PATCH 14/43] fix: validate business data source pool sizes --- .../mcp/core/props/BusinessDataSourcesProperties.java | 8 -------- 1 file changed, 8 deletions(-) diff --git a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java index 1b5d1993a21..994127452b2 100644 --- a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java +++ b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java @@ -81,15 +81,7 @@ public void afterPropertiesSet() { props.setPassword(env.getProperty(prefix + "password")); props.setDatasource(env.getProperty(prefix + "datasource", "druid")); props.setMinConn(env.getProperty(prefix + "minConn", Integer.class, DEFAULT_DB_MIN_CONN)); - if (props.getMinConn() <= 0 || props.getMinConn() > DEFAULT_DB_MIN_CONN) { - LOGGER.warn("The minimum number of connections for a data source: {} is not compliant", name); - continue; - } props.setMaxConn(env.getProperty(prefix + "maxConn", Integer.class, DEFAULT_DB_MAX_CONN)); - if (props.getMaxConn() <= 0 || props.getMaxConn() > DEFAULT_DB_MAX_CONN) { - LOGGER.warn("The maximum number of connections for a data source: {} is not compliant", name); - continue; - } props.setMaxWait(env.getProperty(prefix + "maxWait", Long.class, 5000L)); if (!validateDataSourceProperties(props, name)) { From 9f34fd4b4b867913cc17d6f06c36b4a293dcdc6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Wed, 3 Jun 2026 22:43:39 +0800 Subject: [PATCH 15/43] fix: allow SELECT queries with where clauses --- .../org/apache/seata/mcp/store/SqlExecutionTemplate.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/console/src/main/java/org/apache/seata/mcp/store/SqlExecutionTemplate.java b/console/src/main/java/org/apache/seata/mcp/store/SqlExecutionTemplate.java index 225f8aac536..d2fd602b0e8 100644 --- a/console/src/main/java/org/apache/seata/mcp/store/SqlExecutionTemplate.java +++ b/console/src/main/java/org/apache/seata/mcp/store/SqlExecutionTemplate.java @@ -69,12 +69,6 @@ public List> query(String resourceId, String sql, Object... throw new StoreException("The query valid failed,Only query operations are allowed:" + sql); } conn = getConnection(resourceId); - if (params == null || params.length == 0) { - if ((sql.contains("where") || sql.contains("WHERE"))) { - throw new StoreException( - "Query contains WHERE clause but no parameters were provided. This may lead to unintended full table scans and is not allowed."); - } - } ps = conn.prepareStatement(sql); if (params != null) { for (int i = 0; i < params.length; i++) { From 777a9c9d81001c08515abb0e3d0ff267bb07bfde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Wed, 3 Jun 2026 22:44:28 +0800 Subject: [PATCH 16/43] fix: remove sample business datasource passwords --- namingserver/src/main/resources/application.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/namingserver/src/main/resources/application.yml b/namingserver/src/main/resources/application.yml index ae562950961..5030bf46ec0 100644 --- a/namingserver/src/main/resources/application.yml +++ b/namingserver/src/main/resources/application.yml @@ -77,7 +77,7 @@ seata: driverClassName: com.mysql.cj.jdbc.Driver # Currently, only mysql is supported url: username: root - password: Il02mayi + password: datasource: druid # Optional connection pool type (druid/hikari/dbcp) minConn: 5 maxConn: 50 @@ -88,7 +88,7 @@ seata: driverClassName: com.mysql.cj.jdbc.Driver url: username: root - password: root + password: datasource: dbcp minConn: 5 maxConn: 50 From 0b4d32ffc133e6bc37068eac94deb9c516b3dd6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Wed, 3 Jun 2026 22:45:12 +0800 Subject: [PATCH 17/43] fix: validate MCP datasource driver before generation --- .../apache/seata/mcp/store/db/AbstractMCPDataSourceProvider.java | 1 + 1 file changed, 1 insertion(+) diff --git a/console/src/main/java/org/apache/seata/mcp/store/db/AbstractMCPDataSourceProvider.java b/console/src/main/java/org/apache/seata/mcp/store/db/AbstractMCPDataSourceProvider.java index 194acbb1cca..08622e79986 100644 --- a/console/src/main/java/org/apache/seata/mcp/store/db/AbstractMCPDataSourceProvider.java +++ b/console/src/main/java/org/apache/seata/mcp/store/db/AbstractMCPDataSourceProvider.java @@ -58,6 +58,7 @@ public abstract class AbstractMCPDataSourceProvider { } public DataSource generate() { + validate(); return doGenerate(); } From 6d63cdacc7e98444d85bed79a2853d73a5a486c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Wed, 3 Jun 2026 22:46:08 +0800 Subject: [PATCH 18/43] fix: include resource id in datasource error --- .../main/java/org/apache/seata/mcp/store/DataSourceFactory.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java b/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java index 9090542ee2c..06cb122f2b0 100644 --- a/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java +++ b/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java @@ -67,7 +67,7 @@ public static void removeErrorDataSource(String resourceId, Exception e) { public static DataSource createDataSource( BusinessDataSourcesProperties.DataSourceProperties dataSourceProperties, String resourceId) { if (dataSourceProperties == null) { - throw new StoreException("Cannot find datasource properties:" + dataSourceProperties); + throw new StoreException("Cannot find datasource properties:" + resourceId); } String type = dataSourceProperties.getDatasource(); From 2857e735366f08827792376245be838f0a3e5968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Wed, 3 Jun 2026 22:46:54 +0800 Subject: [PATCH 19/43] fix: close MCP data sources on shutdown --- .../apache/seata/mcp/store/DataSourceFactory.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java b/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java index 06cb122f2b0..251782350f9 100644 --- a/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java +++ b/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java @@ -17,6 +17,7 @@ package org.apache.seata.mcp.store; import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import org.apache.seata.common.exception.StoreException; import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; import org.slf4j.Logger; @@ -39,6 +40,20 @@ public void init() { DataSourceFactory.initAllDataSources(); } + @PreDestroy + public void destroy() { + dataSourceMap.forEach((resourceId, dataSource) -> { + if (dataSource instanceof AutoCloseable) { + try { + ((AutoCloseable) dataSource).close(); + } catch (Exception e) { + LOGGER.warn("Close Business DataSource failed, resourceId: {}", resourceId, e); + } + } + }); + dataSourceMap.clear(); + } + public static void initAllDataSources() { Map datasources = BusinessDataSourcesProperties.getDatasources(); From 7e5352245a9b9c177c9b05186352fee087a7189f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Wed, 3 Jun 2026 22:47:42 +0800 Subject: [PATCH 20/43] fix: lookup MCP datasource properties dynamically --- .../mcp/store/db/AbstractMCPDataSourceProvider.java | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/console/src/main/java/org/apache/seata/mcp/store/db/AbstractMCPDataSourceProvider.java b/console/src/main/java/org/apache/seata/mcp/store/db/AbstractMCPDataSourceProvider.java index 08622e79986..99da503c55d 100644 --- a/console/src/main/java/org/apache/seata/mcp/store/db/AbstractMCPDataSourceProvider.java +++ b/console/src/main/java/org/apache/seata/mcp/store/db/AbstractMCPDataSourceProvider.java @@ -40,9 +40,6 @@ public abstract class AbstractMCPDataSourceProvider { private String resourceId; - protected static final Map DATASOURCE_PROPERTIES = - BusinessDataSourcesProperties.getDatasources(); - private static final String MYSQL_DRIVER_CLASS_NAME = "com.mysql.jdbc.Driver"; private static final String MYSQL8_DRIVER_CLASS_NAME = "com.mysql.cj.jdbc.Driver"; @@ -113,13 +110,15 @@ protected DBType getDBType() { } protected BusinessDataSourcesProperties.DataSourceProperties getDataSourceProperties() { + Map datasourceProperties = + BusinessDataSourcesProperties.getDatasources(); if (StringUtils.isBlank(resourceId)) { - if (DATASOURCE_PROPERTIES.size() == 1) { - return DATASOURCE_PROPERTIES.values().iterator().next(); + if (datasourceProperties.size() == 1) { + return datasourceProperties.values().iterator().next(); } throw new StoreException("resourceId is not specified and there are multiple datasource properties"); } - return DATASOURCE_PROPERTIES.get(resourceId); + return datasourceProperties.get(resourceId); } protected String getDriverClassName() { From 7d11e87b32e94cb1ce41b2ef885cdd09bf6db96d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Wed, 3 Jun 2026 22:48:41 +0800 Subject: [PATCH 21/43] fix: clarify business datasource tool descriptions --- .../org/apache/seata/mcp/tools/BusinessDataSourceTools.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java b/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java index 1c19fe124ea..410d2fd8e4c 100644 --- a/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java +++ b/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java @@ -55,7 +55,7 @@ public List getTableNames( return dataSourceService.getTableNamesBySchema(resourceId); } - @McpTool(description = "Obtained by table nameSchema") + @McpTool(description = "Get the schema (columns) of a table by its name") public List> getTableSchema( @McpToolParam(description = "Table Name") String tableName, @McpToolParam(description = "The identity of the data source, start with jdbc://", required = true) @@ -64,7 +64,9 @@ public List> getTableSchema( return dataSourceService.getTableSchemaByTableName(resourceId, tableName); } - @McpTool(description = "Execute the SQL query result, It can only be used to query business data!!!") + @McpTool( + description = + "Execute a SQL SELECT query against a business data source. Read-only: only SELECT queries are allowed.") public List> runSql( @McpToolParam(description = "SQL statement, String type") String sql, @McpToolParam(description = "The identity of the data source, start with jdbc://", required = true) From 8b8f356b5aacf1310293748daf8dc732b4d516ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Wed, 3 Jun 2026 22:50:32 +0800 Subject: [PATCH 22/43] fix: use managed MySQL connector dependency --- dependencies/pom.xml | 7 ++++++- namingserver/pom.xml | 6 ++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/dependencies/pom.xml b/dependencies/pom.xml index 23a312cbe31..52393adb3f5 100644 --- a/dependencies/pom.xml +++ b/dependencies/pom.xml @@ -124,7 +124,7 @@ 4.8 ${mysql.version} - 8.0.27 + 8.0.33 5.0.0 @@ -303,6 +303,11 @@ mysql-connector-java ${mysql.version} + + com.mysql + mysql-connector-j + ${mysql8.version} + org.postgresql postgresql diff --git a/namingserver/pom.xml b/namingserver/pom.xml index 5dce3ee4627..4dafea9eaa3 100644 --- a/namingserver/pom.xml +++ b/namingserver/pom.xml @@ -36,7 +36,6 @@ 6.2.8 2.0 11.0.22 - 8.0.27 @@ -175,9 +174,8 @@ okhttp - mysql - mysql-connector-java - ${mysql8.version} + com.mysql + mysql-connector-j From 8e6e173e3bb97a9db3463392fd01328f228007e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Wed, 3 Jun 2026 22:54:50 +0800 Subject: [PATCH 23/43] style: format MCP datasource changes --- .../seata/console/filter/MCPBusinessDataSourceFilter.java | 3 ++- .../seata/mcp/core/props/BusinessDataSourcesProperties.java | 5 ++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java b/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java index d26b45a1b76..0fea077b74f 100644 --- a/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java +++ b/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java @@ -41,7 +41,8 @@ public class MCPBusinessDataSourceFilter extends OncePerRequestFilter { public MCPBusinessDataSourceFilter(BusinessDataSourcesProperties properties, List mcpEndpoints) { this.businessDataSourcesProperties = properties; - this.mcpEndpointMatchers = mcpEndpoints.stream().map(AntPathRequestMatcher::new).collect(Collectors.toList()); + this.mcpEndpointMatchers = + mcpEndpoints.stream().map(AntPathRequestMatcher::new).collect(Collectors.toList()); } @Override diff --git a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java index 994127452b2..5e0b11b6c3a 100644 --- a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java +++ b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java @@ -60,9 +60,8 @@ public class BusinessDataSourcesProperties implements InitializingBean { public BusinessDataSourcesProperties(Environment env, ObjectMapper objectMapper) { this.env = env; this.objectMapper = objectMapper; - this.maxDynamicDataSources = - env.getProperty("seata.businessDataSources.max-dynamic-size", Integer.class, - DEFAULT_MAX_DYNAMIC_DATA_SOURCES); + this.maxDynamicDataSources = env.getProperty( + "seata.businessDataSources.max-dynamic-size", Integer.class, DEFAULT_MAX_DYNAMIC_DATA_SOURCES); } @Override From b154354895976a29d6eed06617aacab51ec4c298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Wed, 3 Jun 2026 23:11:00 +0800 Subject: [PATCH 24/43] test: add MCP datasource core behavior tests --- .../MCPBusinessDataSourceFilterTest.java | 125 ++++++++++++++++++ .../BusinessDataSourcesPropertiesTest.java | 89 +++++++++++++ .../mcp/store/SqlExecutionTemplateTest.java | 78 +++++++++++ 3 files changed, 292 insertions(+) create mode 100644 console/src/test/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilterTest.java create mode 100644 console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java create mode 100644 console/src/test/java/org/apache/seata/mcp/store/SqlExecutionTemplateTest.java diff --git a/console/src/test/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilterTest.java b/console/src/test/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilterTest.java new file mode 100644 index 00000000000..1a03bb70893 --- /dev/null +++ b/console/src/test/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilterTest.java @@ -0,0 +1,125 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.console.filter; + +import jakarta.servlet.Servlet; +import jakarta.servlet.ServletConfig; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class MCPBusinessDataSourceFilterTest { + + private static final String DB_CONFIG = + "{\"dbName\":\"biz\",\"dbType\":\"h2\",\"url\":\"jdbc:h2:mem:biz\",\"username\":\"sa\",\"password\":\"pwd\"}"; + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void shouldOnlyProcessMcpEndpoints() throws Exception { + BusinessDataSourcesProperties properties = mock(BusinessDataSourcesProperties.class); + MCPBusinessDataSourceFilter filter = new MCPBusinessDataSourceFilter(properties, List.of("/mcp/**")); + MockHttpServletRequest request = request("POST", "/api/v1/console/users"); + request.addHeader("X-DB-Config", DB_CONFIG); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, new MockFilterChain(statusServlet(HttpServletResponse.SC_NO_CONTENT))); + + assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus()); + verify(properties, never()).registerDataSourceFromJson(DB_CONFIG); + } + + @Test + void shouldRejectUnauthenticatedMcpDataSourceRegistration() throws Exception { + BusinessDataSourcesProperties properties = mock(BusinessDataSourcesProperties.class); + MCPBusinessDataSourceFilter filter = new MCPBusinessDataSourceFilter(properties, List.of("/mcp/**")); + MockHttpServletRequest request = request("POST", "/mcp/message"); + request.addHeader("X-DB-Config", DB_CONFIG); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, new MockFilterChain(statusServlet(HttpServletResponse.SC_NO_CONTENT))); + + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + verify(properties, never()).registerDataSourceFromJson(DB_CONFIG); + } + + @Test + void shouldRegisterDataSourceForAuthenticatedMcpRequest() throws Exception { + BusinessDataSourcesProperties properties = mock(BusinessDataSourcesProperties.class); + MCPBusinessDataSourceFilter filter = new MCPBusinessDataSourceFilter(properties, List.of("/mcp/**")); + MockHttpServletRequest request = request("POST", "/mcp/message"); + request.addHeader("X-DB-Config", DB_CONFIG); + MockHttpServletResponse response = new MockHttpServletResponse(); + SecurityContextHolder.getContext() + .setAuthentication(new UsernamePasswordAuthenticationToken("user", "token", Collections.emptyList())); + + filter.doFilter(request, response, new MockFilterChain(statusServlet(HttpServletResponse.SC_NO_CONTENT))); + + assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus()); + verify(properties).registerDataSourceFromJson(DB_CONFIG); + } + + private Servlet statusServlet(int status) { + return new Servlet() { + @Override + public void init(ServletConfig config) {} + + @Override + public ServletConfig getServletConfig() { + return null; + } + + @Override + public void service(ServletRequest request, ServletResponse response) { + ((HttpServletResponse) response).setStatus(status); + } + + @Override + public String getServletInfo() { + return null; + } + + @Override + public void destroy() {} + }; + } + + private MockHttpServletRequest request(String method, String servletPath) { + MockHttpServletRequest request = new MockHttpServletRequest(method, servletPath); + request.setServletPath(servletPath); + return request; + } +} diff --git a/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java b/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java new file mode 100644 index 00000000000..d6e533af02f --- /dev/null +++ b/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.core.props; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +import java.lang.reflect.Field; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BusinessDataSourcesPropertiesTest { + + @BeforeEach + void setUp() throws Exception { + clearStaticState(); + } + + @AfterEach + void tearDown() throws Exception { + clearStaticState(); + } + + @Test + void shouldRegisterDynamicDataSourceAndRejectSameNameWithDifferentUrl() throws Exception { + BusinessDataSourcesProperties properties = + new BusinessDataSourcesProperties(new MockEnvironment(), new ObjectMapper()); + String config = config("biz", "jdbc:h2:mem:biz"); + + properties.registerDataSourceFromJson(config); + properties.registerDataSourceFromJson(config); + + assertEquals( + "jdbc:h2:mem:biz", + BusinessDataSourcesProperties.getDataSourcesNamesAndResourceIds() + .get("biz")); + assertEquals(1, BusinessDataSourcesProperties.getDatasources().size()); + assertThrows( + IllegalArgumentException.class, + () -> properties.registerDataSourceFromJson(config("biz", "jdbc:h2:mem:other"))); + } + + @Test + void shouldLimitDynamicDataSourceCount() throws Exception { + MockEnvironment env = new MockEnvironment().withProperty("seata.businessDataSources.max-dynamic-size", "1"); + BusinessDataSourcesProperties properties = new BusinessDataSourcesProperties(env, new ObjectMapper()); + + properties.registerDataSourceFromJson(config("biz1", "jdbc:h2:mem:biz1")); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> properties.registerDataSourceFromJson(config("biz2", "jdbc:h2:mem:biz2"))); + assertEquals("The number of dynamic business data sources exceeds the limit: 1", exception.getMessage()); + } + + private String config(String name, String url) { + return "{\"dbName\":\"" + name + + "\",\"dbType\":\"h2\",\"url\":\"" + url + + "\",\"username\":\"sa\",\"password\":\"pwd\",\"minConn\":1,\"maxConn\":2}"; + } + + @SuppressWarnings("unchecked") + private void clearStaticState() throws Exception { + BusinessDataSourcesProperties.getDatasources().clear(); + BusinessDataSourcesProperties.getDataSourcesNamesAndResourceIds().clear(); + Field field = BusinessDataSourcesProperties.class.getDeclaredField("dynamicResourceIds"); + field.setAccessible(true); + ((Set) field.get(null)).clear(); + } +} diff --git a/console/src/test/java/org/apache/seata/mcp/store/SqlExecutionTemplateTest.java b/console/src/test/java/org/apache/seata/mcp/store/SqlExecutionTemplateTest.java new file mode 100644 index 00000000000..6304c2fa231 --- /dev/null +++ b/console/src/test/java/org/apache/seata/mcp/store/SqlExecutionTemplateTest.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.store; + +import org.apache.seata.common.exception.StoreException; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SqlExecutionTemplateTest { + + @Test + void shouldExecuteSelectQueryWithWhereClauseWithoutParameters() throws Exception { + DataSource dataSource = mock(DataSource.class); + Connection connection = mock(Connection.class); + PreparedStatement preparedStatement = mock(PreparedStatement.class); + ResultSet resultSet = mock(ResultSet.class); + ResultSetMetaData metaData = mock(ResultSetMetaData.class); + String sql = "select name from users where id = 1"; + + when(dataSource.getConnection()).thenReturn(connection); + when(connection.prepareStatement(sql)).thenReturn(preparedStatement); + when(preparedStatement.executeQuery()).thenReturn(resultSet); + when(resultSet.getMetaData()).thenReturn(metaData); + when(metaData.getColumnCount()).thenReturn(1); + when(metaData.getColumnLabel(1)).thenReturn("name"); + when(resultSet.next()).thenReturn(true, false); + when(resultSet.getObject(1)).thenReturn("Alice"); + + try (MockedStatic dataSourceFactory = mockStatic(DataSourceFactory.class)) { + dataSourceFactory.when(() -> DataSourceFactory.getDataSource("biz")).thenReturn(dataSource); + + List> rows = new SqlExecutionTemplate().query("biz", sql); + + assertEquals(1, rows.size()); + assertEquals("Alice", rows.get(0).get("name")); + } + verify(connection).prepareStatement(sql); + } + + @Test + void shouldRejectNonSelectSql() { + StoreException exception = assertThrows( + StoreException.class, () -> new SqlExecutionTemplate().query("biz", "delete from users where id = 1")); + + assertEquals( + "The query valid failed,Only query operations are allowed:delete from users where id = 1", + exception.getMessage()); + } +} From c82eafcfcc055de79fb0d2ec8217f59977a681f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Thu, 4 Jun 2026 00:05:41 +0800 Subject: [PATCH 25/43] feat: secure mysql mcp datasource tools --- console/pom.xml | 4 + .../console/config/WebSecurityConfig.java | 8 - .../filter/MCPBusinessDataSourceFilter.java | 86 ---- .../seata/mcp/core/constant/SqlConstant.java | 12 +- .../props/BusinessDataSourcesProperties.java | 379 ++++++++++++------ .../mcp/core/secret/EnvSecretResolver.java | 46 +++ .../seata/mcp/core/secret/SecretResolver.java | 22 + .../dto/MysqlDataSourceRegisterRequest.java | 114 ++++++ .../mcp/entity/vo/BusinessQueryResult.java | 88 ++++ .../seata/mcp/entity/vo/MysqlColumnInfo.java | 48 +++ .../mcp/entity/vo/MysqlDataSourceInfo.java | 78 ++++ .../entity/vo/MysqlDataSourceTestResult.java | 57 +++ .../seata/mcp/entity/vo/MysqlTableInfo.java | 39 ++ .../service/BusinessDataSourceService.java | 33 +- .../mcp/service/MysqlMetadataService.java | 111 +++++ .../impl/BusinessDataSourceServiceImpl.java | 176 ++++++-- .../seata/mcp/store/DataSourceFactory.java | 30 +- .../seata/mcp/store/SqlExecutionTemplate.java | 97 +++-- .../seata/mcp/store/SqlSafetyValidator.java | 100 +++++ .../mcp/tools/BusinessDataSourceTools.java | 224 +++++++++-- .../MCPBusinessDataSourceFilterTest.java | 125 ------ .../BusinessDataSourcesPropertiesTest.java | 136 +++++-- .../mcp/service/MysqlMetadataServiceTest.java | 78 ++++ .../BusinessDataSourceServiceImplTest.java | 51 +++ .../mcp/store/SqlExecutionTemplateTest.java | 96 ++++- .../tools/BusinessDataSourceToolsTest.java | 85 ++++ 26 files changed, 1828 insertions(+), 495 deletions(-) delete mode 100644 console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java create mode 100644 console/src/main/java/org/apache/seata/mcp/core/secret/EnvSecretResolver.java create mode 100644 console/src/main/java/org/apache/seata/mcp/core/secret/SecretResolver.java create mode 100644 console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java create mode 100644 console/src/main/java/org/apache/seata/mcp/entity/vo/BusinessQueryResult.java create mode 100644 console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlColumnInfo.java create mode 100644 console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlDataSourceInfo.java create mode 100644 console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlDataSourceTestResult.java create mode 100644 console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlTableInfo.java create mode 100644 console/src/main/java/org/apache/seata/mcp/service/MysqlMetadataService.java create mode 100644 console/src/main/java/org/apache/seata/mcp/store/SqlSafetyValidator.java delete mode 100644 console/src/test/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilterTest.java create mode 100644 console/src/test/java/org/apache/seata/mcp/service/MysqlMetadataServiceTest.java create mode 100644 console/src/test/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImplTest.java create mode 100644 console/src/test/java/org/apache/seata/mcp/tools/BusinessDataSourceToolsTest.java diff --git a/console/pom.xml b/console/pom.xml index 6c7ca1549f2..a8764539696 100644 --- a/console/pom.xml +++ b/console/pom.xml @@ -180,6 +180,10 @@ com.zaxxer HikariCP + + com.mysql + mysql-connector-j + diff --git a/console/src/main/java/org/apache/seata/console/config/WebSecurityConfig.java b/console/src/main/java/org/apache/seata/console/config/WebSecurityConfig.java index 2f484355134..dc773eeabb0 100644 --- a/console/src/main/java/org/apache/seata/console/config/WebSecurityConfig.java +++ b/console/src/main/java/org/apache/seata/console/config/WebSecurityConfig.java @@ -18,11 +18,9 @@ import org.apache.seata.common.util.StringUtils; import org.apache.seata.console.filter.JwtAuthenticationTokenFilter; -import org.apache.seata.console.filter.MCPBusinessDataSourceFilter; import org.apache.seata.console.security.CustomUserDetailsServiceImpl; import org.apache.seata.console.security.JwtAuthenticationEntryPoint; import org.apache.seata.console.utils.JwtTokenUtils; -import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; import org.apache.seata.mcp.core.props.MCPProperties; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -88,9 +86,6 @@ public class WebSecurityConfig { @Autowired private MCPProperties mcpProperties; - @Autowired - private BusinessDataSourcesProperties businessDataSourcesProperties; - @Value("${seata.security.ignore.urls:/**}") String ignoreURLs; @@ -141,9 +136,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, Authentication }) .addFilterBefore( new JwtAuthenticationTokenFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class) - .addFilterAfter( - new MCPBusinessDataSourceFilter(businessDataSourcesProperties, mcpEndpoints), - JwtAuthenticationTokenFilter.class) .headers(headers -> headers.cacheControl(cache -> {})); return http.build(); diff --git a/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java b/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java deleted file mode 100644 index 0fea077b74f..00000000000 --- a/console/src/main/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilter.java +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.apache.seata.console.filter; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; -import org.springframework.http.HttpStatus; -import org.springframework.security.authentication.AnonymousAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; -import org.springframework.security.web.util.matcher.RequestMatcher; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.util.List; -import java.util.stream.Collectors; - -public class MCPBusinessDataSourceFilter extends OncePerRequestFilter { - - private final BusinessDataSourcesProperties businessDataSourcesProperties; - - private final List mcpEndpointMatchers; - - public MCPBusinessDataSourceFilter(BusinessDataSourcesProperties properties, List mcpEndpoints) { - this.businessDataSourcesProperties = properties; - this.mcpEndpointMatchers = - mcpEndpoints.stream().map(AntPathRequestMatcher::new).collect(Collectors.toList()); - } - - @Override - protected boolean shouldNotFilter(HttpServletRequest request) { - return mcpEndpointMatchers.stream().noneMatch(matcher -> matcher.matches(request)); - } - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - String combinedHeader = request.getHeader("X-DB-Config"); - if (combinedHeader != null && !combinedHeader.isEmpty()) { - if (!isAuthenticated()) { - response.sendError(HttpStatus.UNAUTHORIZED.value(), "Authentication is required"); - return; - } - String[] jsonConfigs = combinedHeader.split(";"); - for (String jsonDBConfig : jsonConfigs) { - try { - businessDataSourcesProperties.registerDataSourceFromJson(jsonDBConfig.trim()); - } catch (Exception e) { - if (!response.isCommitted()) { - response.sendError( - HttpStatus.BAD_REQUEST.value(), - "The business database parameter in the request header is incorrect: " - + e.getMessage()); - } - return; - } - } - } - filterChain.doFilter(request, response); - } - - private boolean isAuthenticated() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - return authentication != null - && authentication.isAuthenticated() - && !(authentication instanceof AnonymousAuthenticationToken); - } -} diff --git a/console/src/main/java/org/apache/seata/mcp/core/constant/SqlConstant.java b/console/src/main/java/org/apache/seata/mcp/core/constant/SqlConstant.java index 57c559d076a..1209b0020e7 100644 --- a/console/src/main/java/org/apache/seata/mcp/core/constant/SqlConstant.java +++ b/console/src/main/java/org/apache/seata/mcp/core/constant/SqlConstant.java @@ -18,10 +18,18 @@ public class SqlConstant { + public static final String LIST_SCHEMA_SQL = + "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA ORDER BY SCHEMA_NAME"; + public static final String GET_TABLE_NAME_SQL = - "SELECT TABLE_NAME, TABLE_COMMENT FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? "; + "SELECT TABLE_NAME, TABLE_COMMENT FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? " + + "ORDER BY TABLE_NAME"; public static final String GET_SCHEMA_SQL = "SELECT COLUMN_NAME, DATA_TYPE, COLUMN_COMMENT FROM INFORMATION_SCHEMA.COLUMNS " - + "WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?"; + + "WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION"; + + public static final String MYSQL_VALIDATION_SQL = "SELECT 1"; + + public static final String MYSQL_EXPLAIN_PREFIX = "EXPLAIN "; } diff --git a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java index 5e0b11b6c3a..f30d46bc93c 100644 --- a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java +++ b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java @@ -16,9 +16,11 @@ */ package org.apache.seata.mcp.core.props; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.seata.common.util.StringUtils; +import org.apache.seata.mcp.core.secret.SecretResolver; +import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.InitializingBean; @@ -28,10 +30,18 @@ import org.springframework.core.env.PropertySource; import org.springframework.stereotype.Component; +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.HashSet; +import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; +import java.util.stream.Collectors; import static org.apache.seata.common.DefaultValues.DEFAULT_DB_MAX_CONN; import static org.apache.seata.common.DefaultValues.DEFAULT_DB_MIN_CONN; @@ -41,10 +51,19 @@ public class BusinessDataSourcesProperties implements InitializingBean { private final Environment env; + @SuppressWarnings("unused") private final ObjectMapper objectMapper; + private final SecretResolver secretResolver; + private final int maxDynamicDataSources; + private final boolean dynamicRegistrationEnabled; + + private final boolean allowPlainPassword; + + private final Set allowedHosts; + private static final Map datasources = new ConcurrentHashMap<>(); private static final Map dataSourcesNamesAndResourceIds = new ConcurrentHashMap<>(); @@ -53,47 +72,167 @@ public class BusinessDataSourcesProperties implements InitializingBean { private static final String BASE_PREFIX = "seata.businessDataSources."; + private static final String MYSQL_DB_TYPE = "mysql"; + + private static final String MYSQL_DRIVER_CLASS_NAME = "com.mysql.cj.jdbc.Driver"; + + private static final String RESOURCE_ID_PREFIX = "business-ds://"; + private static final int DEFAULT_MAX_DYNAMIC_DATA_SOURCES = 100; + private static final Pattern DATASOURCE_NAME_PATTERN = Pattern.compile("[A-Za-z0-9_.-]+"); + private static final Logger LOGGER = LoggerFactory.getLogger(BusinessDataSourcesProperties.class); - public BusinessDataSourcesProperties(Environment env, ObjectMapper objectMapper) { + public BusinessDataSourcesProperties(Environment env, ObjectMapper objectMapper, SecretResolver secretResolver) { this.env = env; this.objectMapper = objectMapper; + this.secretResolver = secretResolver; this.maxDynamicDataSources = env.getProperty( "seata.businessDataSources.max-dynamic-size", Integer.class, DEFAULT_MAX_DYNAMIC_DATA_SOURCES); + this.dynamicRegistrationEnabled = + env.getProperty("seata.businessDataSources.dynamic-registration.enabled", Boolean.class, false); + this.allowPlainPassword = env.getProperty( + "seata.businessDataSources.dynamic-registration.allow-plain-password", Boolean.class, false); + this.allowedHosts = + parseAllowedHosts(env.getProperty("seata.businessDataSources.dynamic-registration.allowed-hosts", "")); } @Override public void afterPropertiesSet() { - Set dataSourceNames = getDataSourceNames(); - for (String name : dataSourceNames) { DataSourceProperties props = new DataSourceProperties(); String prefix = BASE_PREFIX + name + "."; + props.setName(name); + props.setResourceId(buildResourceId(name)); props.setEnabled(env.getProperty(prefix + "enabled", Boolean.class, true)); - props.setDbType(env.getProperty(prefix + "dbType", "mysql")); - props.setDriverClassName(env.getProperty(prefix + "driverClassName", "com.mysql.cj.jdbc.Driver")); + props.setDynamic(false); + props.setDbType(env.getProperty(prefix + "dbType", MYSQL_DB_TYPE)); + props.setDriverClassName(env.getProperty(prefix + "driverClassName", MYSQL_DRIVER_CLASS_NAME)); props.setUrl(env.getProperty(prefix + "url")); props.setUsername(env.getProperty(prefix + "username")); props.setPassword(env.getProperty(prefix + "password")); + props.setPasswordSecretRef(env.getProperty(prefix + "passwordSecretRef")); + if (!StringUtils.hasText(props.getPassword()) && StringUtils.hasText(props.getPasswordSecretRef())) { + props.setPassword(secretResolver.resolve(props.getPasswordSecretRef())); + } props.setDatasource(env.getProperty(prefix + "datasource", "druid")); props.setMinConn(env.getProperty(prefix + "minConn", Integer.class, DEFAULT_DB_MIN_CONN)); props.setMaxConn(env.getProperty(prefix + "maxConn", Integer.class, DEFAULT_DB_MAX_CONN)); props.setMaxWait(env.getProperty(prefix + "maxWait", Long.class, 5000L)); - + props.setAllowedSchemas(parseAllowedSchemas(env.getProperty(prefix + "allowedSchemas", ""))); if (!validateDataSourceProperties(props, name)) { continue; } + if (props.enabled) { + datasources.put(props.getResourceId(), props); + dataSourcesNamesAndResourceIds.put(name, props.getResourceId()); + } + } + } - String resourceId = getOriginUrl(props.getUrl()); + public synchronized String registerMysqlDataSource(MysqlDataSourceRegisterRequest request) { + if (!dynamicRegistrationEnabled) { + throw new IllegalArgumentException("Dynamic business data source registration is disabled"); + } + DataSourceProperties props = buildDynamicMysqlProperties(request); + String name = props.getName(); + String resourceId = props.getResourceId(); + if (dataSourcesNamesAndResourceIds.containsKey(name) || datasources.containsKey(resourceId)) { + throw new IllegalArgumentException("The data source name has already been registered: " + name); + } + if (dynamicResourceIds.size() >= maxDynamicDataSources) { + throw new IllegalArgumentException( + "The number of dynamic business data sources exceeds the limit: " + maxDynamicDataSources); + } + datasources.put(resourceId, props); + dataSourcesNamesAndResourceIds.put(name, resourceId); + dynamicResourceIds.add(resourceId); + return resourceId; + } - if (props.enabled) { - datasources.put(resourceId, props); - dataSourcesNamesAndResourceIds.put(name, resourceId); + public synchronized DataSourceProperties buildDynamicMysqlProperties(MysqlDataSourceRegisterRequest request) { + if (request == null) { + throw new IllegalArgumentException("Data source registration request cannot be null"); + } + DataSourceProperties props = new DataSourceProperties(); + props.setName(request.getName()); + props.setResourceId(buildResourceId(request.getName())); + props.setEnabled(true); + props.setDynamic(true); + props.setDbType(MYSQL_DB_TYPE); + props.setDriverClassName(MYSQL_DRIVER_CLASS_NAME); + props.setUrl(request.getUrl()); + props.setUsername(request.getUsername()); + props.setPasswordSecretRef(request.getPasswordSecretRef()); + props.setPassword(resolvePassword(request)); + props.setDatasource(StringUtils.hasText(request.getDatasource()) ? request.getDatasource() : "druid"); + props.setMinConn(request.getMinConn() <= 0 ? DEFAULT_DB_MIN_CONN : request.getMinConn()); + props.setMaxConn(request.getMaxConn() <= 0 ? DEFAULT_DB_MAX_CONN : request.getMaxConn()); + props.setMaxWait(request.getMaxWait() == null ? 5000L : request.getMaxWait()); + props.setAllowedSchemas(request.getAllowedSchemas()); + if (!validateDataSourceProperties(props, props.getName())) { + throw new IllegalArgumentException("Business DataSource Properties has failure"); + } + validateDynamicMysqlProperties(props); + return props; + } + + public synchronized String unregisterMysqlDataSource(String name) { + if (!dynamicRegistrationEnabled) { + throw new IllegalArgumentException("Dynamic business data source registration is disabled"); + } + if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("The data source name cannot be empty"); + } + String resourceId = dataSourcesNamesAndResourceIds.get(name); + if (!StringUtils.hasText(resourceId) || !dynamicResourceIds.contains(resourceId)) { + throw new IllegalArgumentException("Dynamic data source is not registered: " + name); + } + datasources.remove(resourceId); + dataSourcesNamesAndResourceIds.remove(name); + dynamicResourceIds.remove(resourceId); + return resourceId; + } + + public List getMysqlDataSourceInfos() { + return dataSourcesNamesAndResourceIds.entrySet().stream() + .map(entry -> toInfo(entry.getKey(), datasources.get(entry.getValue()))) + .filter(info -> info != null) + .collect(Collectors.toList()); + } + + public boolean isAllowedSchema(String resourceId, String schemaName) { + DataSourceProperties props = datasources.get(resourceId); + if (props == null || props.getAllowedSchemas().isEmpty()) { + return true; + } + return props.getAllowedSchemas().stream().anyMatch(schema -> schema.equalsIgnoreCase(schemaName)); + } + + private MysqlDataSourceInfo toInfo(String name, DataSourceProperties props) { + if (props == null) { + return null; + } + MysqlDataSourceInfo info = new MysqlDataSourceInfo(); + info.setName(name); + info.setResourceId(props.getResourceId()); + info.setDatasource(props.getDatasource()); + info.setDynamic(props.isDynamic()); + info.setEnabled(props.isEnabled()); + info.setAllowedSchemas(props.getAllowedSchemas()); + return info; + } + + private String resolvePassword(MysqlDataSourceRegisterRequest request) { + if (StringUtils.hasText(request.getPassword())) { + if (!allowPlainPassword) { + throw new IllegalArgumentException("Plain password is not allowed, use passwordSecretRef"); } + return request.getPassword(); } + return secretResolver.resolve(request.getPasswordSecretRef()); } private boolean validateDataSourceProperties(DataSourceProperties props, String dataSourceName) { @@ -101,175 +240,122 @@ private boolean validateDataSourceProperties(DataSourceProperties props, String LOGGER.error("DataSource configuration cannot be null for: {}", dataSourceName); return false; } - + if (!StringUtils.hasText(props.getName())) { + LOGGER.error("DataSource name cannot be empty"); + return false; + } + if (!DATASOURCE_NAME_PATTERN.matcher(props.getName()).matches()) { + LOGGER.error("DataSource name contains unsupported characters"); + return false; + } if (!StringUtils.hasText(props.getUrl())) { LOGGER.error("Database URL cannot be empty for datasource: {}", dataSourceName); return false; } - if (!StringUtils.hasText(props.getUsername())) { LOGGER.error("Database username cannot be empty for datasource: {}", dataSourceName); return false; } - if (!StringUtils.hasText(props.getPassword())) { LOGGER.error("Database password cannot be empty for datasource: {}", dataSourceName); return false; } - - if (!StringUtils.hasText(props.getDriverClassName())) { - LOGGER.error("Database driver class name cannot be empty for datasource: {}", dataSourceName); + if (!MYSQL_DB_TYPE.equalsIgnoreCase(props.getDbType())) { + LOGGER.error("Only MySQL business data source is supported: {}", dataSourceName); return false; } - - if (!StringUtils.hasText(props.getDbType())) { - LOGGER.error("Database type cannot be empty for datasource: {}", dataSourceName); + if (!MYSQL_DRIVER_CLASS_NAME.equals(props.getDriverClassName())) { + LOGGER.error("Only MySQL 8 driver is supported for datasource: {}", dataSourceName); return false; } - if (props.getMinConn() < 0) { LOGGER.error("Minimum connection count cannot be negative for datasource: {}", dataSourceName); return false; } - if (props.getMaxConn() <= 0) { LOGGER.error("Maximum connection count must be positive for datasource: {}", dataSourceName); return false; } - if (props.getMinConn() > props.getMaxConn()) { LOGGER.error( "Minimum connection count cannot be greater than maximum connection count for datasource: {}", dataSourceName); return false; } - if (props.getMaxWait() != null && props.getMaxWait() < 0) { LOGGER.error("Maximum wait time cannot be negative for datasource: {}", dataSourceName); return false; } - - if (!props.getUrl().toLowerCase().startsWith("jdbc:")) { - LOGGER.error("Invalid JDBC URL format for datasource: {}. URL should start with 'jdbc:'", dataSourceName); - return false; - } return true; } - private DataSourceProperties parseDBPropertyFromJson(JsonNode jsonNode) { - if (jsonNode == null || jsonNode.isEmpty()) { - throw new IllegalArgumentException("JSON configuration cannot be null"); - } - DataSourceProperties props = new DataSourceProperties(); - - props.setDbType(jsonNode.has("dbType") ? jsonNode.get("dbType").asText() : "mysql"); - - String driverClassName = getDefaultDriverClassName(props.getDbType()); - props.setDriverClassName(driverClassName); - - if (!jsonNode.has("url")) { - throw new IllegalArgumentException("The database URL cannot be empty"); - } - props.setUrl(jsonNode.get("url").asText()); - - if (!jsonNode.has("username")) { - throw new IllegalArgumentException("The database username cannot be empty"); + private void validateDynamicMysqlProperties(DataSourceProperties props) { + if (!props.getUrl().toLowerCase(Locale.ROOT).startsWith("jdbc:mysql://")) { + throw new IllegalArgumentException("Only jdbc:mysql:// URL is supported"); } - props.setUsername(jsonNode.get("username").asText()); - - if (!jsonNode.has("password")) { - throw new IllegalArgumentException("The database password cannot be empty"); + String host = parseMysqlHost(props.getUrl()); + if (!allowedHosts.isEmpty() && !allowedHosts.contains(host.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException("MySQL host is not allowed: " + host); } - props.setPassword(jsonNode.get("password").asText()); - - props.setDatasource( - jsonNode.has("datasource") ? jsonNode.get("datasource").asText() : "druid"); - props.setMinConn(jsonNode.has("minConn") ? jsonNode.get("minConn").asInt() : DEFAULT_DB_MIN_CONN); - props.setMaxConn(jsonNode.has("maxConn") ? jsonNode.get("maxConn").asInt() : DEFAULT_DB_MAX_CONN); - props.setMaxWait(jsonNode.has("maxWait") ? jsonNode.get("maxWait").asLong() : 5000L); - - return props; } - public synchronized void registerDataSourceFromJson(String jsonConfig) throws Exception { - JsonNode jsonNode = objectMapper.readTree(jsonConfig); - if (jsonNode == null || jsonNode.isEmpty()) { - throw new IllegalArgumentException("JSON configuration cannot be null"); - } - String name = jsonNode.get("dbName").asText(); - if (!StringUtils.hasText(name)) { - throw new IllegalArgumentException("The data source name cannot be empty"); + private String parseMysqlHost(String url) { + try { + URI uri = URI.create(url.substring("jdbc:".length())); + if (!StringUtils.hasText(uri.getHost())) { + throw new IllegalArgumentException("MySQL host cannot be empty"); + } + return uri.getHost(); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid MySQL JDBC URL"); } + } - DataSourceProperties props = parseDBPropertyFromJson(jsonNode); - if (!validateDataSourceProperties(props, name)) { - throw new IllegalArgumentException("Business DataSource Properties has failure"); - } - String resourceId = getOriginUrl(props.getUrl()); - String existingResourceId = dataSourcesNamesAndResourceIds.get(name); - if (StringUtils.hasText(existingResourceId) && !existingResourceId.equals(resourceId)) { - throw new IllegalArgumentException("The data source name has already been registered: " + name); - } - if (datasources.containsKey(resourceId)) { - dataSourcesNamesAndResourceIds.putIfAbsent(name, resourceId); - return; + private Set parseAllowedHosts(String hosts) { + if (!StringUtils.hasText(hosts)) { + return Collections.emptySet(); } - if (dynamicResourceIds.size() >= maxDynamicDataSources) { - throw new IllegalArgumentException( - "The number of dynamic business data sources exceeds the limit: " + maxDynamicDataSources); - } - - datasources.put(resourceId, props); - dynamicResourceIds.add(resourceId); - dataSourcesNamesAndResourceIds.put(name, resourceId); + return Arrays.stream(hosts.split(",")) + .map(String::trim) + .filter(StringUtils::isNotBlank) + .map(host -> host.toLowerCase(Locale.ROOT)) + .collect(Collectors.toSet()); } - private static String getDefaultDriverClassName(String dbType) { - switch (dbType.toLowerCase()) { - case "postgresql": - return "org.postgresql.Driver"; - case "oracle": - return "oracle.jdbc.driver.OracleDriver"; - case "sqlserver": - return "com.microsoft.sqlserver.jdbc.SQLServerDriver"; - case "h2": - return "org.h2.Driver"; - case "mysql": - default: - return "com.mysql.cj.jdbc.Driver"; + private List parseAllowedSchemas(String schemas) { + if (!StringUtils.hasText(schemas)) { + return Collections.emptyList(); } + return Arrays.stream(schemas.split(",")) + .map(String::trim) + .filter(StringUtils::isNotBlank) + .collect(Collectors.toList()); } - private String getOriginUrl(String url) { - int index = url.indexOf("?"); - if (index != -1) { - url = url.substring(0, index); + private String buildResourceId(String name) { + if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("The data source name cannot be empty"); } - return url; + return RESOURCE_ID_PREFIX + name.trim(); } private Set getDataSourceNames() { Set names = new HashSet<>(); - if (env instanceof ConfigurableEnvironment) { ConfigurableEnvironment configEnv = (ConfigurableEnvironment) env; - Set processedNames = new HashSet<>(); - for (PropertySource propertySource : configEnv.getPropertySources()) { if (propertySource instanceof EnumerablePropertySource) { EnumerablePropertySource enumSource = (EnumerablePropertySource) propertySource; - for (String propertyName : enumSource.getPropertyNames()) { if (propertyName.startsWith(BASE_PREFIX)) { String[] parts = propertyName.split("\\."); if (parts.length > 3) { String dsName = parts[2]; - if (!processedNames.contains(dsName)) { - if (env.containsProperty(BASE_PREFIX + dsName + ".url")) { - names.add(dsName); - processedNames.add(dsName); - } + if (!processedNames.contains(dsName) + && env.containsProperty(BASE_PREFIX + dsName + ".url")) { + names.add(dsName); + processedNames.add(dsName); } } } @@ -292,17 +378,32 @@ public static Set getResourceIds() { return datasources.keySet(); } + public static Set getDynamicResourceIds() { + return dynamicResourceIds; + } + + static void clear() { + datasources.clear(); + dataSourcesNamesAndResourceIds.clear(); + dynamicResourceIds.clear(); + } + public static class DataSourceProperties { private boolean enabled = true; - private String dbType = "mysql"; - private String driverClassName = "com.mysql.cj.jdbc.Driver"; + private boolean dynamic; + private String name; + private String resourceId; + private String dbType = MYSQL_DB_TYPE; + private String driverClassName = MYSQL_DRIVER_CLASS_NAME; private String url = ""; - private String username = "mysql"; - private String password = "mysql"; + private String username = ""; + private String password = ""; + private String passwordSecretRef = ""; private String datasource = "druid"; private int minConn = DEFAULT_DB_MIN_CONN; private int maxConn = DEFAULT_DB_MAX_CONN; private Long maxWait = 5000L; + private List allowedSchemas = new ArrayList<>(); public boolean isEnabled() { return enabled; @@ -312,6 +413,30 @@ public void setEnabled(boolean enabled) { this.enabled = enabled; } + public boolean isDynamic() { + return dynamic; + } + + public void setDynamic(boolean dynamic) { + this.dynamic = dynamic; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getResourceId() { + return resourceId; + } + + public void setResourceId(String resourceId) { + this.resourceId = resourceId; + } + public Long getMaxWait() { return maxWait; } @@ -360,6 +485,14 @@ public void setPassword(String password) { this.password = password; } + public String getPasswordSecretRef() { + return passwordSecretRef; + } + + public void setPasswordSecretRef(String passwordSecretRef) { + this.passwordSecretRef = passwordSecretRef; + } + public String getDatasource() { return datasource; } @@ -383,5 +516,13 @@ public int getMaxConn() { public void setMaxConn(int maxConn) { this.maxConn = maxConn; } + + public List getAllowedSchemas() { + return allowedSchemas; + } + + public void setAllowedSchemas(List allowedSchemas) { + this.allowedSchemas = allowedSchemas == null ? Collections.emptyList() : allowedSchemas; + } } } diff --git a/console/src/main/java/org/apache/seata/mcp/core/secret/EnvSecretResolver.java b/console/src/main/java/org/apache/seata/mcp/core/secret/EnvSecretResolver.java new file mode 100644 index 00000000000..64e374b1362 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/core/secret/EnvSecretResolver.java @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.core.secret; + +import org.apache.seata.common.util.StringUtils; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +@Component +public class EnvSecretResolver implements SecretResolver { + + private final Environment environment; + + public EnvSecretResolver(Environment environment) { + this.environment = environment; + } + + @Override + public String resolve(String secretRef) { + if (!StringUtils.hasText(secretRef)) { + throw new IllegalArgumentException("passwordSecretRef cannot be empty"); + } + String secret = environment.getProperty(secretRef); + if (!StringUtils.hasText(secret)) { + secret = System.getenv(secretRef); + } + if (!StringUtils.hasText(secret)) { + throw new IllegalArgumentException("Unable to resolve password secret: " + secretRef); + } + return secret; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/core/secret/SecretResolver.java b/console/src/main/java/org/apache/seata/mcp/core/secret/SecretResolver.java new file mode 100644 index 00000000000..e4b9fa38bfc --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/core/secret/SecretResolver.java @@ -0,0 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.core.secret; + +public interface SecretResolver { + + String resolve(String secretRef); +} diff --git a/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java b/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java new file mode 100644 index 00000000000..e8e9009b219 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java @@ -0,0 +1,114 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.entity.dto; + +import java.util.Collections; +import java.util.List; + +public class MysqlDataSourceRegisterRequest { + + private String name; + private String url; + private String username; + private String passwordSecretRef; + private String password; + private String datasource = "druid"; + private int minConn = 10; + private int maxConn = 100; + private Long maxWait = 5000L; + private List allowedSchemas = Collections.emptyList(); + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPasswordSecretRef() { + return passwordSecretRef; + } + + public void setPasswordSecretRef(String passwordSecretRef) { + this.passwordSecretRef = passwordSecretRef; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getDatasource() { + return datasource; + } + + public void setDatasource(String datasource) { + this.datasource = datasource; + } + + public int getMinConn() { + return minConn; + } + + public void setMinConn(int minConn) { + this.minConn = minConn; + } + + public int getMaxConn() { + return maxConn; + } + + public void setMaxConn(int maxConn) { + this.maxConn = maxConn; + } + + public Long getMaxWait() { + return maxWait; + } + + public void setMaxWait(Long maxWait) { + this.maxWait = maxWait; + } + + public List getAllowedSchemas() { + return allowedSchemas; + } + + public void setAllowedSchemas(List allowedSchemas) { + this.allowedSchemas = allowedSchemas == null ? Collections.emptyList() : allowedSchemas; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/entity/vo/BusinessQueryResult.java b/console/src/main/java/org/apache/seata/mcp/entity/vo/BusinessQueryResult.java new file mode 100644 index 00000000000..d18484e69e7 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/entity/vo/BusinessQueryResult.java @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.entity.vo; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class BusinessQueryResult { + + private List columns = new ArrayList<>(); + private List> rows = new ArrayList<>(); + private int rowCount; + private boolean truncated; + private int maxRows; + private long executionTimeMs; + private String resourceId; + + public List getColumns() { + return columns; + } + + public void setColumns(List columns) { + this.columns = columns; + } + + public List> getRows() { + return rows; + } + + public void setRows(List> rows) { + this.rows = rows; + } + + public int getRowCount() { + return rowCount; + } + + public void setRowCount(int rowCount) { + this.rowCount = rowCount; + } + + public boolean isTruncated() { + return truncated; + } + + public void setTruncated(boolean truncated) { + this.truncated = truncated; + } + + public int getMaxRows() { + return maxRows; + } + + public void setMaxRows(int maxRows) { + this.maxRows = maxRows; + } + + public long getExecutionTimeMs() { + return executionTimeMs; + } + + public void setExecutionTimeMs(long executionTimeMs) { + this.executionTimeMs = executionTimeMs; + } + + public String getResourceId() { + return resourceId; + } + + public void setResourceId(String resourceId) { + this.resourceId = resourceId; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlColumnInfo.java b/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlColumnInfo.java new file mode 100644 index 00000000000..cd1ef75477e --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlColumnInfo.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.entity.vo; + +public class MysqlColumnInfo { + + private String columnName; + private String dataType; + private String columnComment; + + public String getColumnName() { + return columnName; + } + + public void setColumnName(String columnName) { + this.columnName = columnName; + } + + public String getDataType() { + return dataType; + } + + public void setDataType(String dataType) { + this.dataType = dataType; + } + + public String getColumnComment() { + return columnComment; + } + + public void setColumnComment(String columnComment) { + this.columnComment = columnComment; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlDataSourceInfo.java b/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlDataSourceInfo.java new file mode 100644 index 00000000000..a5110d0f6a6 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlDataSourceInfo.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.entity.vo; + +import java.util.Collections; +import java.util.List; + +public class MysqlDataSourceInfo { + + private String name; + private String resourceId; + private String datasource; + private boolean dynamic; + private boolean enabled; + private List allowedSchemas = Collections.emptyList(); + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getResourceId() { + return resourceId; + } + + public void setResourceId(String resourceId) { + this.resourceId = resourceId; + } + + public String getDatasource() { + return datasource; + } + + public void setDatasource(String datasource) { + this.datasource = datasource; + } + + public boolean isDynamic() { + return dynamic; + } + + public void setDynamic(boolean dynamic) { + this.dynamic = dynamic; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public List getAllowedSchemas() { + return allowedSchemas; + } + + public void setAllowedSchemas(List allowedSchemas) { + this.allowedSchemas = allowedSchemas == null ? Collections.emptyList() : allowedSchemas; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlDataSourceTestResult.java b/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlDataSourceTestResult.java new file mode 100644 index 00000000000..2bc4a37eb23 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlDataSourceTestResult.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.entity.vo; + +public class MysqlDataSourceTestResult { + + private boolean success; + private String message; + private String validationQuery; + private long elapsedMs; + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getValidationQuery() { + return validationQuery; + } + + public void setValidationQuery(String validationQuery) { + this.validationQuery = validationQuery; + } + + public long getElapsedMs() { + return elapsedMs; + } + + public void setElapsedMs(long elapsedMs) { + this.elapsedMs = elapsedMs; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlTableInfo.java b/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlTableInfo.java new file mode 100644 index 00000000000..78d45735ad3 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlTableInfo.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.entity.vo; + +public class MysqlTableInfo { + + private String tableName; + private String tableComment; + + public String getTableName() { + return tableName; + } + + public void setTableName(String tableName) { + this.tableName = tableName; + } + + public String getTableComment() { + return tableComment; + } + + public void setTableComment(String tableComment) { + this.tableComment = tableComment; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/service/BusinessDataSourceService.java b/console/src/main/java/org/apache/seata/mcp/service/BusinessDataSourceService.java index e30e3a6fd23..9432676760a 100644 --- a/console/src/main/java/org/apache/seata/mcp/service/BusinessDataSourceService.java +++ b/console/src/main/java/org/apache/seata/mcp/service/BusinessDataSourceService.java @@ -16,13 +16,40 @@ */ package org.apache.seata.mcp.service; +import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; +import org.apache.seata.mcp.entity.vo.BusinessQueryResult; +import org.apache.seata.mcp.entity.vo.MysqlColumnInfo; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceTestResult; +import org.apache.seata.mcp.entity.vo.MysqlTableInfo; + import java.util.List; import java.util.Map; public interface BusinessDataSourceService { - List getTableNamesBySchema(String resourceId); + List getMysqlDataSources(); + + String registerMysqlDataSource(MysqlDataSourceRegisterRequest request); + + String unregisterMysqlDataSource(String name); + + MysqlDataSourceTestResult testMysqlDataSource(MysqlDataSourceRegisterRequest request); + + List listMysqlSchemas(String resourceId); + + List getMysqlTableNames(String resourceId, String schemaName); + + List getMysqlTableSchema(String resourceId, String schemaName, String tableName); + + BusinessQueryResult runSql(String sql, String resourceId); - List> getTableSchemaByTableName(String resourceId, String tableName); + BusinessQueryResult queryMysqlTable( + String resourceId, + String schemaName, + String tableName, + List columns, + Map filters, + Integer limit); - List> runSql(String sql, String resourceId); + BusinessQueryResult explainMysqlSql(String resourceId, String sql); } diff --git a/console/src/main/java/org/apache/seata/mcp/service/MysqlMetadataService.java b/console/src/main/java/org/apache/seata/mcp/service/MysqlMetadataService.java new file mode 100644 index 00000000000..83b62be0f30 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/service/MysqlMetadataService.java @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.service; + +import org.apache.seata.common.exception.StoreException; +import org.apache.seata.common.util.StringUtils; +import org.apache.seata.mcp.core.constant.SqlConstant; +import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; +import org.apache.seata.mcp.entity.vo.BusinessQueryResult; +import org.apache.seata.mcp.entity.vo.MysqlColumnInfo; +import org.apache.seata.mcp.entity.vo.MysqlTableInfo; +import org.apache.seata.mcp.store.SqlExecutionTemplate; +import org.apache.seata.mcp.store.SqlSafetyValidator; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class MysqlMetadataService { + + private final SqlExecutionTemplate sqlExecutionTemplate; + + private final SqlSafetyValidator sqlSafetyValidator; + + private final BusinessDataSourcesProperties businessDataSourcesProperties; + + public MysqlMetadataService( + SqlExecutionTemplate sqlExecutionTemplate, + SqlSafetyValidator sqlSafetyValidator, + BusinessDataSourcesProperties businessDataSourcesProperties) { + this.sqlExecutionTemplate = sqlExecutionTemplate; + this.sqlSafetyValidator = sqlSafetyValidator; + this.businessDataSourcesProperties = businessDataSourcesProperties; + } + + public List listSchemas(String resourceId) { + return sqlExecutionTemplate.trustedQuery(resourceId, SqlConstant.LIST_SCHEMA_SQL).getRows().stream() + .map(row -> String.valueOf(row.get("SCHEMA_NAME"))) + .collect(Collectors.toList()); + } + + public List listTables(String resourceId, String schemaName) { + validateAllowedSchema(resourceId, schemaName); + return sqlExecutionTemplate + .trustedQuery(resourceId, SqlConstant.GET_TABLE_NAME_SQL, schemaName) + .getRows() + .stream() + .map(this::toTableInfo) + .collect(Collectors.toList()); + } + + public List describeTable(String resourceId, String schemaName, String tableName) { + validateAllowedSchema(resourceId, schemaName); + if (!StringUtils.hasText(tableName)) { + throw new StoreException("tableName cannot be empty"); + } + return sqlExecutionTemplate + .trustedQuery(resourceId, SqlConstant.GET_SCHEMA_SQL, schemaName, tableName) + .getRows() + .stream() + .map(this::toColumnInfo) + .collect(Collectors.toList()); + } + + public BusinessQueryResult explainSql(String resourceId, String sql) { + sqlSafetyValidator.validateMysqlSelect(sql); + return sqlExecutionTemplate.trustedQuery(resourceId, SqlConstant.MYSQL_EXPLAIN_PREFIX + sql); + } + + private void validateAllowedSchema(String resourceId, String schemaName) { + if (!StringUtils.hasText(schemaName)) { + throw new StoreException("schemaName cannot be empty"); + } + if (!businessDataSourcesProperties.isAllowedSchema(resourceId, schemaName)) { + throw new StoreException("schemaName is not allowed: " + schemaName); + } + } + + private MysqlTableInfo toTableInfo(Map row) { + MysqlTableInfo info = new MysqlTableInfo(); + info.setTableName(String.valueOf(row.get("TABLE_NAME"))); + Object comment = row.get("TABLE_COMMENT"); + info.setTableComment(comment == null ? "" : String.valueOf(comment)); + return info; + } + + private MysqlColumnInfo toColumnInfo(Map row) { + MysqlColumnInfo info = new MysqlColumnInfo(); + info.setColumnName(String.valueOf(row.get("COLUMN_NAME"))); + info.setDataType(String.valueOf(row.get("DATA_TYPE"))); + Object comment = row.get("COLUMN_COMMENT"); + info.setColumnComment(comment == null ? "" : String.valueOf(comment)); + return info; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java b/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java index b5bf48d748e..30ce8583726 100644 --- a/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java +++ b/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java @@ -19,68 +19,178 @@ import org.apache.seata.common.exception.StoreException; import org.apache.seata.common.util.StringUtils; import org.apache.seata.mcp.core.constant.SqlConstant; +import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; +import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; +import org.apache.seata.mcp.entity.vo.BusinessQueryResult; +import org.apache.seata.mcp.entity.vo.MysqlColumnInfo; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceTestResult; +import org.apache.seata.mcp.entity.vo.MysqlTableInfo; import org.apache.seata.mcp.service.BusinessDataSourceService; +import org.apache.seata.mcp.service.MysqlMetadataService; +import org.apache.seata.mcp.store.DataSourceFactory; import org.apache.seata.mcp.store.SqlExecutionTemplate; import org.springframework.stereotype.Service; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; +import java.util.regex.Pattern; @Service public class BusinessDataSourceServiceImpl implements BusinessDataSourceService { + private static final Pattern IDENTIFIER_PATTERN = Pattern.compile("[A-Za-z0-9_$]+"); + private final SqlExecutionTemplate sqlExecutionTemplate; - public BusinessDataSourceServiceImpl(SqlExecutionTemplate sqlExecutionTemplate) { + private final BusinessDataSourcesProperties businessDataSourcesProperties; + + private final MysqlMetadataService mysqlMetadataService; + + public BusinessDataSourceServiceImpl( + SqlExecutionTemplate sqlExecutionTemplate, + BusinessDataSourcesProperties businessDataSourcesProperties, + MysqlMetadataService mysqlMetadataService) { this.sqlExecutionTemplate = sqlExecutionTemplate; + this.businessDataSourcesProperties = businessDataSourcesProperties; + this.mysqlMetadataService = mysqlMetadataService; } @Override - public List getTableNamesBySchema(String resourceId) { - String schema = getSchemaNameByResourceId(resourceId); - if (StringUtils.isBlank(schema)) { - throw new StoreException("failed to get schema by resourceId: " + resourceId); - } else { - List> maps = - sqlExecutionTemplate.query(resourceId, SqlConstant.GET_TABLE_NAME_SQL, schema); - return maps.stream() - .map(map -> { - String tableName = String.valueOf(map.get("TABLE_NAME")); - String tableComment = String.valueOf(map.get("TABLE_COMMENT")); - return tableName + " (" + tableComment + ")"; - }) - .collect(Collectors.toList()); + public List getMysqlDataSources() { + return businessDataSourcesProperties.getMysqlDataSourceInfos(); + } + + @Override + public String registerMysqlDataSource(MysqlDataSourceRegisterRequest request) { + return businessDataSourcesProperties.registerMysqlDataSource(request); + } + + @Override + public String unregisterMysqlDataSource(String name) { + String resourceId = businessDataSourcesProperties.unregisterMysqlDataSource(name); + DataSourceFactory.removeDataSource(resourceId); + return resourceId; + } + + @Override + public MysqlDataSourceTestResult testMysqlDataSource(MysqlDataSourceRegisterRequest request) { + long start = System.currentTimeMillis(); + MysqlDataSourceTestResult result = new MysqlDataSourceTestResult(); + result.setValidationQuery(SqlConstant.MYSQL_VALIDATION_SQL); + try { + BusinessDataSourcesProperties.DataSourceProperties props = + businessDataSourcesProperties.buildDynamicMysqlProperties(request); + Class.forName(props.getDriverClassName()); + try (Connection connection = + DriverManager.getConnection(props.getUrl(), props.getUsername(), props.getPassword()); + PreparedStatement statement = connection.prepareStatement(SqlConstant.MYSQL_VALIDATION_SQL)) { + statement.setQueryTimeout(5); + statement.executeQuery(); + } + result.setSuccess(true); + result.setMessage("OK"); + } catch (Exception e) { + result.setSuccess(false); + result.setMessage("Connection test failed"); } + result.setElapsedMs(System.currentTimeMillis() - start); + return result; + } + + @Override + public List listMysqlSchemas(String resourceId) { + return mysqlMetadataService.listSchemas(resourceId); + } + + @Override + public List getMysqlTableNames(String resourceId, String schemaName) { + return mysqlMetadataService.listTables(resourceId, schemaName); } @Override - public List> getTableSchemaByTableName(String resourceId, String tableName) { - String schema = getSchemaNameByResourceId(resourceId); - if (StringUtils.isBlank(schema)) { - throw new StoreException("failed to get schema by resourceId: " + resourceId); + public List getMysqlTableSchema(String resourceId, String schemaName, String tableName) { + return mysqlMetadataService.describeTable(resourceId, schemaName, tableName); + } + + @Override + public BusinessQueryResult runSql(String sql, String resourceId) { + return sqlExecutionTemplate.query(resourceId, sql); + } + + @Override + public BusinessQueryResult queryMysqlTable( + String resourceId, + String schemaName, + String tableName, + List columns, + Map filters, + Integer limit) { + validateAllowedSchema(resourceId, schemaName); + validateIdentifier("schemaName", schemaName); + validateIdentifier("tableName", tableName); + + StringBuilder sql = new StringBuilder("SELECT "); + if (columns == null || columns.isEmpty()) { + sql.append("*"); } else { - return sqlExecutionTemplate.query(resourceId, SqlConstant.GET_SCHEMA_SQL, schema, tableName); + sql.append(buildColumnList(columns)); + } + sql.append(" FROM ").append(quote(schemaName)).append(".").append(quote(tableName)); + + List params = new ArrayList<>(); + if (filters != null && !filters.isEmpty()) { + sql.append(" WHERE "); + boolean first = true; + for (Map.Entry entry : filters.entrySet()) { + validateIdentifier("filter column", entry.getKey()); + if (!first) { + sql.append(" AND "); + } + sql.append(quote(entry.getKey())).append(" = ?"); + params.add(entry.getValue()); + first = false; + } } + + int maxRows = limit == null || limit <= 0 ? Integer.MAX_VALUE : limit; + return sqlExecutionTemplate.queryWithMaxRows(resourceId, sql.toString(), maxRows, params.toArray()); } @Override - public List> runSql(String sql, String resourceId) { - if (sql.contains("undo_log")) { - throw new StoreException( - "If you do not use SQL to query undo_log data, use analyzeUndoLog to query and analyze undo_log"); + public BusinessQueryResult explainMysqlSql(String resourceId, String sql) { + return mysqlMetadataService.explainSql(resourceId, sql); + } + + private void validateAllowedSchema(String resourceId, String schemaName) { + if (!businessDataSourcesProperties.isAllowedSchema(resourceId, schemaName)) { + throw new StoreException("schemaName is not allowed: " + schemaName); } - return sqlExecutionTemplate.query(resourceId, sql); } - public String getSchemaNameByResourceId(String resourceId) { - if (StringUtils.isBlank(resourceId)) { - return ""; + private String buildColumnList(List columns) { + StringBuilder builder = new StringBuilder(); + for (String column : columns) { + validateIdentifier("column", column); + if (builder.length() > 0) { + builder.append(", "); + } + builder.append(quote(column)); } - int idx = resourceId.lastIndexOf("/"); - if (idx != -1 && idx != resourceId.length() - 1) { - return resourceId.substring(idx + 1); + return builder.toString(); + } + + private void validateIdentifier(String field, String value) { + if (!StringUtils.hasText(value) || !IDENTIFIER_PATTERN.matcher(value).matches()) { + throw new StoreException(field + " contains unsupported characters"); } - return ""; + } + + private String quote(String identifier) { + return "`" + identifier + "`"; } } diff --git a/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java b/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java index 251782350f9..99158d5d85a 100644 --- a/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java +++ b/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java @@ -42,15 +42,7 @@ public void init() { @PreDestroy public void destroy() { - dataSourceMap.forEach((resourceId, dataSource) -> { - if (dataSource instanceof AutoCloseable) { - try { - ((AutoCloseable) dataSource).close(); - } catch (Exception e) { - LOGGER.warn("Close Business DataSource failed, resourceId: {}", resourceId, e); - } - } - }); + dataSourceMap.forEach(DataSourceFactory::closeDataSource); dataSourceMap.clear(); } @@ -73,10 +65,24 @@ public static DataSource getDataSource(String resourceId) { } public static void removeErrorDataSource(String resourceId, Exception e) { - dataSourceMap.remove(resourceId); + closeDataSource(resourceId, dataSourceMap.remove(resourceId)); + LOGGER.info("Delete Business DataSource, resourceId: {}", resourceId); + throw new StoreException("The Business DataSource: " + resourceId + " can't be connected"); + } + + public static void removeDataSource(String resourceId) { + closeDataSource(resourceId, dataSourceMap.remove(resourceId)); LOGGER.info("Delete Business DataSource, resourceId: {}", resourceId); - throw new StoreException( - "The Business DataSource: " + resourceId + " can't be connected due to: " + e.getMessage()); + } + + private static void closeDataSource(String resourceId, DataSource dataSource) { + if (dataSource instanceof AutoCloseable) { + try { + ((AutoCloseable) dataSource).close(); + } catch (Exception e) { + LOGGER.warn("Close Business DataSource failed, resourceId: {}", resourceId, e); + } + } } public static DataSource createDataSource( diff --git a/console/src/main/java/org/apache/seata/mcp/store/SqlExecutionTemplate.java b/console/src/main/java/org/apache/seata/mcp/store/SqlExecutionTemplate.java index d2fd602b0e8..d2c1625ba6d 100644 --- a/console/src/main/java/org/apache/seata/mcp/store/SqlExecutionTemplate.java +++ b/console/src/main/java/org/apache/seata/mcp/store/SqlExecutionTemplate.java @@ -17,9 +17,10 @@ package org.apache.seata.mcp.store; import org.apache.seata.common.exception.StoreException; -import org.apache.seata.common.util.StringUtils; +import org.apache.seata.mcp.entity.vo.BusinessQueryResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import javax.sql.DataSource; @@ -30,46 +31,70 @@ import java.sql.SQLException; import java.sql.Statement; import java.util.ArrayList; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.regex.Pattern; @Service public class SqlExecutionTemplate { - private static final Pattern SELECT_PATTERN = - Pattern.compile("^\\s*SELECT\\b.*", Pattern.CASE_INSENSITIVE | Pattern.DOTALL); - private static final Logger LOGGER = LoggerFactory.getLogger(SqlExecutionTemplate.class); + private final SqlSafetyValidator sqlSafetyValidator; + + @Value("${seata.mcp.query.max-rows:500}") + private int maxRows = 500; + + @Value("${seata.mcp.query.timeout-seconds:30}") + private int timeoutSeconds = 30; + + @Value("${seata.mcp.query.fetch-size:100}") + private int fetchSize = 100; + + public SqlExecutionTemplate(SqlSafetyValidator sqlSafetyValidator) { + this.sqlSafetyValidator = sqlSafetyValidator; + } + private DataSource getDataSource(String resourceId) { try { return DataSourceFactory.getDataSource(resourceId); } catch (Exception e) { - LOGGER.error("Failed to get the data source, resourceId: {}", resourceId, e); + LOGGER.error("Failed to get the data source, resourceId: {}", resourceId); throw new StoreException("Unable to get the data source: " + resourceId); } } - private boolean validateQuerySql(String sql) { - if (sql == null || StringUtils.isBlank(sql)) { - return false; - } - return SELECT_PATTERN.matcher(sql).matches(); + public BusinessQueryResult query(String resourceId, String sql, Object... params) { + sqlSafetyValidator.validateMysqlSelect(sql); + return doQuery(resourceId, sql, maxRows, true, params); + } + + public BusinessQueryResult queryWithMaxRows(String resourceId, String sql, int queryMaxRows, Object... params) { + sqlSafetyValidator.validateMysqlSelect(sql); + int effectiveMaxRows = queryMaxRows <= 0 ? maxRows : Math.min(queryMaxRows, maxRows); + return doQuery(resourceId, sql, effectiveMaxRows, true, params); + } + + public BusinessQueryResult trustedQuery(String resourceId, String sql, Object... params) { + return doQuery(resourceId, sql, maxRows, false, params); } - public List> query(String resourceId, String sql, Object... params) { + private BusinessQueryResult doQuery( + String resourceId, String sql, int effectiveMaxRows, boolean limitRows, Object... params) { Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; + long start = System.currentTimeMillis(); try { - if (!validateQuerySql(sql)) { - throw new StoreException("The query valid failed,Only query operations are allowed:" + sql); - } conn = getConnection(resourceId); + conn.setReadOnly(true); ps = conn.prepareStatement(sql); + ps.setQueryTimeout(timeoutSeconds); + ps.setFetchSize(fetchSize); + if (limitRows) { + ps.setMaxRows(effectiveMaxRows + 1); + } if (params != null) { for (int i = 0; i < params.length; i++) { ps.setObject(i + 1, params[i]); @@ -79,33 +104,57 @@ public List> query(String resourceId, String sql, Object... rs = ps.executeQuery(); ResultSetMetaData metaData = rs.getMetaData(); int columnCount = metaData.getColumnCount(); + List columns = new ArrayList<>(); + for (int i = 1; i <= columnCount; i++) { + columns.add(metaData.getColumnLabel(i)); + } List> results = new ArrayList<>(); while (rs.next()) { - Map row = new HashMap<>(); + if (limitRows && results.size() >= effectiveMaxRows) { + return buildResult(resourceId, columns, results, effectiveMaxRows, true, start); + } + Map row = new LinkedHashMap<>(); for (int i = 1; i <= columnCount; i++) { - String columnName = metaData.getColumnLabel(i); + String columnName = columns.get(i - 1); Object value = rs.getObject(i); row.put(columnName, value); } results.add(row); } - return results; + return buildResult(resourceId, columns, results, effectiveMaxRows, false, start); } catch (SQLException e) { - LOGGER.error("The query failed, resourceId: {}, sql: {}", resourceId, sql, e); - throw new StoreException("The query execution failed: " + e.getMessage()); + LOGGER.error("The query failed, resourceId: {}", resourceId); + throw new StoreException("The query execution failed"); } finally { - LOGGER.info("User query business datasource with sql: {}", sql); closeResources(rs, ps, conn); } } public Map queryForObject(String resourceId, String sql, Object... params) { - List> results = query(resourceId, sql, params); + List> results = query(resourceId, sql, params).getRows(); return results.isEmpty() ? null : results.get(0); } + private BusinessQueryResult buildResult( + String resourceId, + List columns, + List> rows, + int effectiveMaxRows, + boolean truncated, + long start) { + BusinessQueryResult result = new BusinessQueryResult(); + result.setResourceId(resourceId); + result.setColumns(columns); + result.setRows(rows); + result.setRowCount(rows.size()); + result.setTruncated(truncated); + result.setMaxRows(effectiveMaxRows); + result.setExecutionTimeMs(System.currentTimeMillis() - start); + return result; + } + private void closeResources(ResultSet rs, Statement stmt, Connection conn) { if (rs != null) { try { @@ -140,7 +189,7 @@ private Connection getConnection(String resourceId) { try { return getDataSource(resourceId).getConnection(); } catch (Exception e) { - LOGGER.error("Get The Business DataSource Connection: {} failed due to: {}", resourceId, e.getMessage()); + LOGGER.error("Get The Business DataSource Connection: {} failed", resourceId); DataSourceFactory.removeErrorDataSource(resourceId, e); throw new StoreException(e); } diff --git a/console/src/main/java/org/apache/seata/mcp/store/SqlSafetyValidator.java b/console/src/main/java/org/apache/seata/mcp/store/SqlSafetyValidator.java new file mode 100644 index 00000000000..626736eb5a2 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/store/SqlSafetyValidator.java @@ -0,0 +1,100 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.store; + +import com.alibaba.druid.DbType; +import com.alibaba.druid.sql.SQLUtils; +import com.alibaba.druid.sql.ast.SQLStatement; +import com.alibaba.druid.sql.ast.statement.SQLSelectQueryBlock; +import com.alibaba.druid.sql.ast.statement.SQLSelectStatement; +import com.alibaba.druid.sql.visitor.SQLASTVisitorAdapter; +import com.alibaba.druid.sql.visitor.SchemaStatVisitor; +import com.alibaba.druid.stat.TableStat; +import org.apache.seata.common.exception.StoreException; +import org.apache.seata.common.util.StringUtils; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Locale; + +@Component +public class SqlSafetyValidator { + + private static final String UNDO_LOG_TABLE = "undo_log"; + + public SQLSelectStatement validateMysqlSelect(String sql) { + if (StringUtils.isBlank(sql)) { + throw new StoreException("SQL cannot be empty"); + } + List statements; + try { + statements = SQLUtils.parseStatements(sql, DbType.mysql); + } catch (Exception e) { + throw new StoreException("SQL parse failed"); + } + if (statements.size() != 1) { + throw new StoreException("Only a single SELECT statement is allowed"); + } + SQLStatement statement = statements.get(0); + if (!(statement instanceof SQLSelectStatement)) { + throw new StoreException("Only SELECT statements are allowed"); + } + SQLSelectStatement selectStatement = (SQLSelectStatement) statement; + if (hasLockClause(selectStatement)) { + throw new StoreException("SELECT locking clauses are not allowed"); + } + if (containsUndoLog(selectStatement)) { + throw new StoreException("Querying undo_log is not allowed"); + } + return selectStatement; + } + + private boolean hasLockClause(SQLSelectStatement statement) { + final boolean[] result = new boolean[] {false}; + statement.accept(new SQLASTVisitorAdapter() { + @Override + public boolean visit(SQLSelectQueryBlock queryBlock) { + if (queryBlock.isForUpdate() || queryBlock.isForShare()) { + result[0] = true; + } + return true; + } + }); + return result[0]; + } + + private boolean containsUndoLog(SQLSelectStatement statement) { + SchemaStatVisitor visitor = SQLUtils.createSchemaStatVisitor(DbType.mysql); + statement.accept(visitor); + for (TableStat.Name name : visitor.getTables().keySet()) { + String tableName = normalizeTableName(name.getName()); + if (UNDO_LOG_TABLE.equals(tableName)) { + return true; + } + } + return false; + } + + private String normalizeTableName(String tableName) { + String normalized = tableName; + int index = normalized.lastIndexOf('.'); + if (index >= 0) { + normalized = normalized.substring(index + 1); + } + return normalized.replace("`", "").replace("\"", "").toLowerCase(Locale.ROOT); + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java b/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java index 410d2fd8e4c..913c5c500ae 100644 --- a/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java +++ b/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java @@ -16,67 +16,221 @@ */ package org.apache.seata.mcp.tools; -import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seata.common.exception.StoreException; +import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; +import org.apache.seata.mcp.entity.vo.BusinessQueryResult; +import org.apache.seata.mcp.entity.vo.MysqlColumnInfo; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceTestResult; +import org.apache.seata.mcp.entity.vo.MysqlTableInfo; import org.apache.seata.mcp.service.BusinessDataSourceService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springaicommunity.mcp.annotation.McpResource; import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; -import java.util.Collections; +import java.util.Collection; import java.util.List; import java.util.Map; @Service public class BusinessDataSourceTools { + private static final Logger LOGGER = LoggerFactory.getLogger(BusinessDataSourceTools.class); + private final BusinessDataSourceService dataSourceService; - public BusinessDataSourceTools(BusinessDataSourceService dataSourceService) { + private final ObjectMapper objectMapper; + + public BusinessDataSourceTools(BusinessDataSourceService dataSourceService, ObjectMapper objectMapper) { this.dataSourceService = dataSourceService; + this.objectMapper = objectMapper; } - private static final Logger LOGGER = LoggerFactory.getLogger(BusinessDataSourceTools.class); + @McpTool(description = "Register a dynamic MySQL business data source. Admin only.") + public String registerMysqlDataSource( + @McpToolParam(description = "MySQL data source registration request", required = true) + MysqlDataSourceRegisterRequest request) { + requireAdmin(); + LOGGER.info("User tries to register MySQL business data source"); + return dataSourceService.registerMysqlDataSource(request); + } - @McpTool( - description = - "Get the identity and name of the business data source. Important!!!: key is name, value is resourceId") - public Map getResourceIds() { - LOGGER.info("User try to get resource ids"); - return BusinessDataSourcesProperties.getDataSourcesNamesAndResourceIds(); + @McpTool(description = "Unregister a dynamic MySQL business data source. Admin only.") + public String unregisterMysqlDataSource( + @McpToolParam(description = "The data source name", required = true) String name) { + requireAdmin(); + LOGGER.info("User tries to unregister MySQL business data source: {}", name); + return dataSourceService.unregisterMysqlDataSource(name); } - @McpTool(description = "Get all available table names") - public List getTableNames( - @McpToolParam(description = "The identity of the data source, start with jdbc://", required = true) - String resourceId) { - LOGGER.info("User try to get all table names, resource id {}", resourceId); - return dataSourceService.getTableNamesBySchema(resourceId); + @McpTool(description = "Test a MySQL business data source registration request. Admin only.") + public MysqlDataSourceTestResult testMysqlDataSource( + @McpToolParam(description = "MySQL data source registration request", required = true) + MysqlDataSourceRegisterRequest request) { + requireAdmin(); + LOGGER.info("User tries to test MySQL business data source"); + return dataSourceService.testMysqlDataSource(request); + } + + @McpTool(description = "Get all MySQL business data sources without sensitive fields") + public List getMysqlDataSources() { + LOGGER.info("User tries to get MySQL business data sources"); + return dataSourceService.getMysqlDataSources(); } - @McpTool(description = "Get the schema (columns) of a table by its name") - public List> getTableSchema( - @McpToolParam(description = "Table Name") String tableName, - @McpToolParam(description = "The identity of the data source, start with jdbc://", required = true) + @McpTool(description = "List MySQL schemas in a business data source") + public List listMysqlSchemas( + @McpToolParam( + description = "The identity of the data source, for example business-ds://biz", + required = true) String resourceId) { - LOGGER.info("User try to get table schema, tableName: {}, resourceId: {}", tableName, resourceId); - return dataSourceService.getTableSchemaByTableName(resourceId, tableName); + LOGGER.info("User tries to list MySQL schemas, resourceId: {}", resourceId); + return dataSourceService.listMysqlSchemas(resourceId); + } + + @McpTool(description = "Get MySQL table names in a schema") + public List getMysqlTableNames( + @McpToolParam( + description = "The identity of the data source, for example business-ds://biz", + required = true) + String resourceId, + @McpToolParam(description = "MySQL schema name", required = true) String schemaName) { + LOGGER.info("User tries to get MySQL table names, resourceId: {}, schemaName: {}", resourceId, schemaName); + return dataSourceService.getMysqlTableNames(resourceId, schemaName); + } + + @McpTool(description = "Get MySQL table columns in a schema") + public List getMysqlTableSchema( + @McpToolParam( + description = "The identity of the data source, for example business-ds://biz", + required = true) + String resourceId, + @McpToolParam(description = "MySQL schema name", required = true) String schemaName, + @McpToolParam(description = "MySQL table name", required = true) String tableName) { + LOGGER.info( + "User tries to get MySQL table schema, resourceId: {}, schemaName: {}, tableName: {}", + resourceId, + schemaName, + tableName); + return dataSourceService.getMysqlTableSchema(resourceId, schemaName, tableName); + } + + @McpTool(description = "Query a MySQL table with optional column list, equality filters, and row limit") + public BusinessQueryResult queryMysqlTable( + @McpToolParam( + description = "The identity of the data source, for example business-ds://biz", + required = true) + String resourceId, + @McpToolParam(description = "MySQL schema name", required = true) String schemaName, + @McpToolParam(description = "MySQL table name", required = true) String tableName, + @McpToolParam(description = "Column names to select", required = false) List columns, + @McpToolParam(description = "Equality filters keyed by column name", required = false) + Map filters, + @McpToolParam(description = "Maximum rows to return", required = false) Integer limit) { + LOGGER.info( + "User tries to query MySQL table, resourceId: {}, schemaName: {}, tableName: {}", + resourceId, + schemaName, + tableName); + return dataSourceService.queryMysqlTable(resourceId, schemaName, tableName, columns, filters, limit); } - @McpTool( - description = - "Execute a SQL SELECT query against a business data source. Read-only: only SELECT queries are allowed.") - public List> runSql( - @McpToolParam(description = "SQL statement, String type") String sql, - @McpToolParam(description = "The identity of the data source, start with jdbc://", required = true) + @McpTool(description = "Explain a safe MySQL SELECT SQL statement") + public BusinessQueryResult explainMysqlSql( + @McpToolParam( + description = "The identity of the data source, for example business-ds://biz", + required = true) + String resourceId, + @McpToolParam(description = "MySQL SELECT SQL statement", required = true) String sql) { + LOGGER.info("User tries to explain MySQL sql, resourceId: {}", resourceId); + return dataSourceService.explainMysqlSql(resourceId, sql); + } + + @McpTool(description = "Execute a safe MySQL SELECT query against a business data source") + public BusinessQueryResult runSql( + @McpToolParam(description = "MySQL SELECT SQL statement", required = true) String sql, + @McpToolParam( + description = "The identity of the data source, for example business-ds://biz", + required = true) String resourceId) { - LOGGER.info("User try to run sql: {}, resourceId: {}", sql, resourceId); - List> result = dataSourceService.runSql(sql, resourceId); - result.add( - Collections.singletonMap( - "Important!!!", - "If it is related to data analysis, statistics, etc., Please generate a table and attach an analysis statement to the user for viewing")); - return result; + LOGGER.info("User tries to run MySQL sql, resourceId: {}", resourceId); + return dataSourceService.runSql(sql, resourceId); + } + + @McpResource( + name = "mysqlDataSources", + title = "MySQL business data sources", + uri = "mysql-db://datasources", + description = "MySQL business data source list without sensitive fields", + mimeType = "application/json") + public String mysqlDataSourcesResource() { + return toJson(dataSourceService.getMysqlDataSources()); + } + + @McpResource( + name = "mysqlSchemas", + title = "MySQL schemas", + uri = "mysql-db://{resourceId}/schemas", + description = "MySQL schema list for a business data source", + mimeType = "application/json") + public String mysqlSchemasResource(String resourceId) { + return toJson(dataSourceService.listMysqlSchemas(resourceId)); + } + + @McpResource( + name = "mysqlTables", + title = "MySQL tables", + uri = "mysql-db://{resourceId}/{schemaName}/tables", + description = "MySQL table list for a business schema", + mimeType = "application/json") + public String mysqlTablesResource(String resourceId, String schemaName) { + return toJson(dataSourceService.getMysqlTableNames(resourceId, schemaName)); + } + + @McpResource( + name = "mysqlTableSchema", + title = "MySQL table schema", + uri = "mysql-db://{resourceId}/{schemaName}/{tableName}/schema", + description = "MySQL column list for a business table", + mimeType = "application/json") + public String mysqlTableSchemaResource(String resourceId, String schemaName, String tableName) { + return toJson(dataSourceService.getMysqlTableSchema(resourceId, schemaName, tableName)); + } + + private void requireAdmin() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null + || !authentication.isAuthenticated() + || authentication instanceof AnonymousAuthenticationToken) { + throw new AccessDeniedException("Admin authority is required"); + } + Collection authorities = authentication.getAuthorities(); + if (authorities == null || authorities.isEmpty()) { + return; + } + boolean admin = authorities.stream() + .map(GrantedAuthority::getAuthority) + .anyMatch(authority -> "ADMIN".equals(authority) || "ROLE_ADMIN".equals(authority)); + if (!admin) { + throw new AccessDeniedException("Admin authority is required"); + } + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new StoreException("Unable to serialize MCP resource"); + } } } diff --git a/console/src/test/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilterTest.java b/console/src/test/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilterTest.java deleted file mode 100644 index 1a03bb70893..00000000000 --- a/console/src/test/java/org/apache/seata/console/filter/MCPBusinessDataSourceFilterTest.java +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.apache.seata.console.filter; - -import jakarta.servlet.Servlet; -import jakarta.servlet.ServletConfig; -import jakarta.servlet.ServletRequest; -import jakarta.servlet.ServletResponse; -import jakarta.servlet.http.HttpServletResponse; -import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockFilterChain; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; - -import java.util.Collections; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; - -class MCPBusinessDataSourceFilterTest { - - private static final String DB_CONFIG = - "{\"dbName\":\"biz\",\"dbType\":\"h2\",\"url\":\"jdbc:h2:mem:biz\",\"username\":\"sa\",\"password\":\"pwd\"}"; - - @AfterEach - void tearDown() { - SecurityContextHolder.clearContext(); - } - - @Test - void shouldOnlyProcessMcpEndpoints() throws Exception { - BusinessDataSourcesProperties properties = mock(BusinessDataSourcesProperties.class); - MCPBusinessDataSourceFilter filter = new MCPBusinessDataSourceFilter(properties, List.of("/mcp/**")); - MockHttpServletRequest request = request("POST", "/api/v1/console/users"); - request.addHeader("X-DB-Config", DB_CONFIG); - MockHttpServletResponse response = new MockHttpServletResponse(); - - filter.doFilter(request, response, new MockFilterChain(statusServlet(HttpServletResponse.SC_NO_CONTENT))); - - assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus()); - verify(properties, never()).registerDataSourceFromJson(DB_CONFIG); - } - - @Test - void shouldRejectUnauthenticatedMcpDataSourceRegistration() throws Exception { - BusinessDataSourcesProperties properties = mock(BusinessDataSourcesProperties.class); - MCPBusinessDataSourceFilter filter = new MCPBusinessDataSourceFilter(properties, List.of("/mcp/**")); - MockHttpServletRequest request = request("POST", "/mcp/message"); - request.addHeader("X-DB-Config", DB_CONFIG); - MockHttpServletResponse response = new MockHttpServletResponse(); - - filter.doFilter(request, response, new MockFilterChain(statusServlet(HttpServletResponse.SC_NO_CONTENT))); - - assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); - verify(properties, never()).registerDataSourceFromJson(DB_CONFIG); - } - - @Test - void shouldRegisterDataSourceForAuthenticatedMcpRequest() throws Exception { - BusinessDataSourcesProperties properties = mock(BusinessDataSourcesProperties.class); - MCPBusinessDataSourceFilter filter = new MCPBusinessDataSourceFilter(properties, List.of("/mcp/**")); - MockHttpServletRequest request = request("POST", "/mcp/message"); - request.addHeader("X-DB-Config", DB_CONFIG); - MockHttpServletResponse response = new MockHttpServletResponse(); - SecurityContextHolder.getContext() - .setAuthentication(new UsernamePasswordAuthenticationToken("user", "token", Collections.emptyList())); - - filter.doFilter(request, response, new MockFilterChain(statusServlet(HttpServletResponse.SC_NO_CONTENT))); - - assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus()); - verify(properties).registerDataSourceFromJson(DB_CONFIG); - } - - private Servlet statusServlet(int status) { - return new Servlet() { - @Override - public void init(ServletConfig config) {} - - @Override - public ServletConfig getServletConfig() { - return null; - } - - @Override - public void service(ServletRequest request, ServletResponse response) { - ((HttpServletResponse) response).setStatus(status); - } - - @Override - public String getServletInfo() { - return null; - } - - @Override - public void destroy() {} - }; - } - - private MockHttpServletRequest request(String method, String servletPath) { - MockHttpServletRequest request = new MockHttpServletRequest(method, servletPath); - request.setServletPath(servletPath); - return request; - } -} diff --git a/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java b/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java index d6e533af02f..d0a2cc7a498 100644 --- a/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java +++ b/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java @@ -17,73 +17,141 @@ package org.apache.seata.mcp.core.props; import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seata.mcp.core.secret.EnvSecretResolver; +import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.mock.env.MockEnvironment; -import java.lang.reflect.Field; -import java.util.Set; +import java.util.Collections; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; class BusinessDataSourcesPropertiesTest { @BeforeEach - void setUp() throws Exception { - clearStaticState(); + void setUp() { + BusinessDataSourcesProperties.clear(); } @AfterEach - void tearDown() throws Exception { - clearStaticState(); + void tearDown() { + BusinessDataSourcesProperties.clear(); } @Test - void shouldRegisterDynamicDataSourceAndRejectSameNameWithDifferentUrl() throws Exception { + void shouldRejectDynamicRegistrationWhenDisabledByDefault() { BusinessDataSourcesProperties properties = - new BusinessDataSourcesProperties(new MockEnvironment(), new ObjectMapper()); - String config = config("biz", "jdbc:h2:mem:biz"); + newProperties(new MockEnvironment().withProperty("MYSQL_PASS", "pwd")); - properties.registerDataSourceFromJson(config); - properties.registerDataSourceFromJson(config); + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> properties.registerMysqlDataSource(request("biz", "localhost"))); + + assertEquals("Dynamic business data source registration is disabled", exception.getMessage()); + } + + @Test + void shouldResolvePasswordSecretRefAndRegisterMysqlResourceId() { + BusinessDataSourcesProperties properties = newProperties(enabledEnv().withProperty("MYSQL_PASS", "pwd")); + + String resourceId = properties.registerMysqlDataSource(request("biz", "localhost")); + assertEquals("business-ds://biz", resourceId); + BusinessDataSourcesProperties.DataSourceProperties props = + BusinessDataSourcesProperties.getDatasources().get(resourceId); + assertEquals("pwd", props.getPassword()); + assertEquals("MYSQL_PASS", props.getPasswordSecretRef()); assertEquals( - "jdbc:h2:mem:biz", + "business-ds://biz", BusinessDataSourcesProperties.getDataSourcesNamesAndResourceIds() .get("biz")); - assertEquals(1, BusinessDataSourcesProperties.getDatasources().size()); - assertThrows( - IllegalArgumentException.class, - () -> properties.registerDataSourceFromJson(config("biz", "jdbc:h2:mem:other"))); + assertEquals(1, properties.getMysqlDataSourceInfos().size()); + } + + @Test + void shouldRejectPlainPasswordByDefault() { + BusinessDataSourcesProperties properties = newProperties(enabledEnv()); + MysqlDataSourceRegisterRequest request = request("biz", "localhost"); + request.setPassword("pwd"); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> properties.registerMysqlDataSource(request)); + + assertEquals("Plain password is not allowed, use passwordSecretRef", exception.getMessage()); + } + + @Test + void shouldRejectNonMysqlJdbcUrl() { + BusinessDataSourcesProperties properties = newProperties(enabledEnv().withProperty("MYSQL_PASS", "pwd")); + MysqlDataSourceRegisterRequest request = request("biz", "localhost"); + request.setUrl("jdbc:postgresql://localhost:5432/app"); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> properties.registerMysqlDataSource(request)); + + assertEquals("Only jdbc:mysql:// URL is supported", exception.getMessage()); + } + + @Test + void shouldApplyHostAllowlist() { + MockEnvironment env = enabledEnv() + .withProperty("MYSQL_PASS", "pwd") + .withProperty("seata.businessDataSources.dynamic-registration.allowed-hosts", "db.example.com"); + BusinessDataSourcesProperties properties = newProperties(env); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> properties.registerMysqlDataSource(request("biz", "localhost"))); + + assertEquals("MySQL host is not allowed: localhost", exception.getMessage()); + assertEquals("business-ds://biz2", properties.registerMysqlDataSource(request("biz2", "db.example.com"))); } @Test - void shouldLimitDynamicDataSourceCount() throws Exception { - MockEnvironment env = new MockEnvironment().withProperty("seata.businessDataSources.max-dynamic-size", "1"); - BusinessDataSourcesProperties properties = new BusinessDataSourcesProperties(env, new ObjectMapper()); + void shouldRejectDuplicateDataSourceName() { + BusinessDataSourcesProperties properties = newProperties(enabledEnv().withProperty("MYSQL_PASS", "pwd")); - properties.registerDataSourceFromJson(config("biz1", "jdbc:h2:mem:biz1")); + properties.registerMysqlDataSource(request("biz", "localhost")); IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> properties.registerDataSourceFromJson(config("biz2", "jdbc:h2:mem:biz2"))); - assertEquals("The number of dynamic business data sources exceeds the limit: 1", exception.getMessage()); + IllegalArgumentException.class, () -> properties.registerMysqlDataSource(request("biz", "localhost"))); + + assertEquals("The data source name has already been registered: biz", exception.getMessage()); + } + + @Test + void shouldUnregisterDynamicDataSourceAndCleanConfig() { + BusinessDataSourcesProperties properties = newProperties(enabledEnv().withProperty("MYSQL_PASS", "pwd")); + properties.registerMysqlDataSource(request("biz", "localhost")); + + String resourceId = properties.unregisterMysqlDataSource("biz"); + + assertEquals("business-ds://biz", resourceId); + assertFalse(BusinessDataSourcesProperties.getDatasources().containsKey(resourceId)); + assertFalse(BusinessDataSourcesProperties.getDataSourcesNamesAndResourceIds() + .containsKey("biz")); + assertFalse(BusinessDataSourcesProperties.getDynamicResourceIds().contains(resourceId)); + } + + private BusinessDataSourcesProperties newProperties(MockEnvironment env) { + return new BusinessDataSourcesProperties(env, new ObjectMapper(), new EnvSecretResolver(env)); } - private String config(String name, String url) { - return "{\"dbName\":\"" + name - + "\",\"dbType\":\"h2\",\"url\":\"" + url - + "\",\"username\":\"sa\",\"password\":\"pwd\",\"minConn\":1,\"maxConn\":2}"; + private MockEnvironment enabledEnv() { + return new MockEnvironment().withProperty("seata.businessDataSources.dynamic-registration.enabled", "true"); } - @SuppressWarnings("unchecked") - private void clearStaticState() throws Exception { - BusinessDataSourcesProperties.getDatasources().clear(); - BusinessDataSourcesProperties.getDataSourcesNamesAndResourceIds().clear(); - Field field = BusinessDataSourcesProperties.class.getDeclaredField("dynamicResourceIds"); - field.setAccessible(true); - ((Set) field.get(null)).clear(); + private MysqlDataSourceRegisterRequest request(String name, String host) { + MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); + request.setName(name); + request.setUrl("jdbc:mysql://" + host + ":3306/app"); + request.setUsername("readonly"); + request.setPasswordSecretRef("MYSQL_PASS"); + request.setMinConn(1); + request.setMaxConn(2); + request.setAllowedSchemas(Collections.singletonList("app")); + return request; } } diff --git a/console/src/test/java/org/apache/seata/mcp/service/MysqlMetadataServiceTest.java b/console/src/test/java/org/apache/seata/mcp/service/MysqlMetadataServiceTest.java new file mode 100644 index 00000000000..aad8ef7971a --- /dev/null +++ b/console/src/test/java/org/apache/seata/mcp/service/MysqlMetadataServiceTest.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.service; + +import org.apache.seata.common.exception.StoreException; +import org.apache.seata.mcp.core.constant.SqlConstant; +import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; +import org.apache.seata.mcp.entity.vo.BusinessQueryResult; +import org.apache.seata.mcp.store.SqlExecutionTemplate; +import org.apache.seata.mcp.store.SqlSafetyValidator; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MysqlMetadataServiceTest { + + @Test + void shouldQueryTablesWithSchemaParameter() { + SqlExecutionTemplate sqlExecutionTemplate = mock(SqlExecutionTemplate.class); + BusinessDataSourcesProperties properties = mock(BusinessDataSourcesProperties.class); + BusinessQueryResult result = result(row("TABLE_NAME", "orders", "TABLE_COMMENT", "business orders")); + when(properties.isAllowedSchema("business-ds://biz", "app")).thenReturn(true); + when(sqlExecutionTemplate.trustedQuery("business-ds://biz", SqlConstant.GET_TABLE_NAME_SQL, "app")) + .thenReturn(result); + + MysqlMetadataService service = + new MysqlMetadataService(sqlExecutionTemplate, new SqlSafetyValidator(), properties); + + assertEquals( + "orders", service.listTables("business-ds://biz", "app").get(0).getTableName()); + verify(sqlExecutionTemplate).trustedQuery("business-ds://biz", SqlConstant.GET_TABLE_NAME_SQL, "app"); + } + + @Test + void shouldRejectExplainForUnsafeSql() { + MysqlMetadataService service = new MysqlMetadataService( + mock(SqlExecutionTemplate.class), new SqlSafetyValidator(), mock(BusinessDataSourcesProperties.class)); + + assertThrows(StoreException.class, () -> service.explainSql("business-ds://biz", "delete from users")); + } + + private BusinessQueryResult result(Map row) { + BusinessQueryResult result = new BusinessQueryResult(); + result.setRows(Collections.singletonList(row)); + result.setRowCount(1); + return result; + } + + private Map row(Object... values) { + Map row = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i += 2) { + row.put(String.valueOf(values[i]), values[i + 1]); + } + return row; + } +} diff --git a/console/src/test/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImplTest.java b/console/src/test/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImplTest.java new file mode 100644 index 00000000000..ee61bf96708 --- /dev/null +++ b/console/src/test/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImplTest.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.service.impl; + +import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; +import org.apache.seata.mcp.service.MysqlMetadataService; +import org.apache.seata.mcp.store.DataSourceFactory; +import org.apache.seata.mcp.store.SqlExecutionTemplate; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class BusinessDataSourceServiceImplTest { + + @Test + void shouldRemovePoolWhenUnregisteringDynamicDataSource() { + SqlExecutionTemplate sqlExecutionTemplate = mock(SqlExecutionTemplate.class); + BusinessDataSourcesProperties properties = mock(BusinessDataSourcesProperties.class); + MysqlMetadataService metadataService = mock(MysqlMetadataService.class); + BusinessDataSourceServiceImpl service = + new BusinessDataSourceServiceImpl(sqlExecutionTemplate, properties, metadataService); + when(properties.unregisterMysqlDataSource("biz")).thenReturn("business-ds://biz"); + + try (MockedStatic dataSourceFactory = mockStatic(DataSourceFactory.class)) { + String resourceId = service.unregisterMysqlDataSource("biz"); + + assertEquals("business-ds://biz", resourceId); + dataSourceFactory.verify(() -> DataSourceFactory.removeDataSource("business-ds://biz")); + } + verify(properties).unregisterMysqlDataSource("biz"); + } +} diff --git a/console/src/test/java/org/apache/seata/mcp/store/SqlExecutionTemplateTest.java b/console/src/test/java/org/apache/seata/mcp/store/SqlExecutionTemplateTest.java index 6304c2fa231..2dbc8df2aba 100644 --- a/console/src/test/java/org/apache/seata/mcp/store/SqlExecutionTemplateTest.java +++ b/console/src/test/java/org/apache/seata/mcp/store/SqlExecutionTemplateTest.java @@ -17,19 +17,20 @@ package org.apache.seata.mcp.store; import org.apache.seata.common.exception.StoreException; +import org.apache.seata.mcp.entity.vo.BusinessQueryResult; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; +import org.springframework.test.util.ReflectionTestUtils; import javax.sql.DataSource; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.ResultSetMetaData; -import java.util.List; -import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verify; @@ -38,13 +39,13 @@ class SqlExecutionTemplateTest { @Test - void shouldExecuteSelectQueryWithWhereClauseWithoutParameters() throws Exception { + void shouldExecuteSelectQueryWithStructuredResult() throws Exception { DataSource dataSource = mock(DataSource.class); Connection connection = mock(Connection.class); PreparedStatement preparedStatement = mock(PreparedStatement.class); ResultSet resultSet = mock(ResultSet.class); ResultSetMetaData metaData = mock(ResultSetMetaData.class); - String sql = "select name from users where id = 1"; + String sql = "select name from users where id = ?"; when(dataSource.getConnection()).thenReturn(connection); when(connection.prepareStatement(sql)).thenReturn(preparedStatement); @@ -58,21 +59,88 @@ void shouldExecuteSelectQueryWithWhereClauseWithoutParameters() throws Exception try (MockedStatic dataSourceFactory = mockStatic(DataSourceFactory.class)) { dataSourceFactory.when(() -> DataSourceFactory.getDataSource("biz")).thenReturn(dataSource); - List> rows = new SqlExecutionTemplate().query("biz", sql); + BusinessQueryResult result = new SqlExecutionTemplate(new SqlSafetyValidator()).query("biz", sql, 1); - assertEquals(1, rows.size()); - assertEquals("Alice", rows.get(0).get("name")); + assertEquals("biz", result.getResourceId()); + assertEquals(1, result.getColumns().size()); + assertEquals("name", result.getColumns().get(0)); + assertEquals(1, result.getRowCount()); + assertEquals("Alice", result.getRows().get(0).get("name")); } - verify(connection).prepareStatement(sql); + verify(connection).setReadOnly(true); + verify(preparedStatement).setQueryTimeout(30); + verify(preparedStatement).setFetchSize(100); + verify(preparedStatement).setMaxRows(501); + verify(preparedStatement).setObject(1, 1); } @Test - void shouldRejectNonSelectSql() { - StoreException exception = assertThrows( - StoreException.class, () -> new SqlExecutionTemplate().query("biz", "delete from users where id = 1")); + void shouldReturnTruncatedWhenRowsExceedMaxRows() throws Exception { + DataSource dataSource = mock(DataSource.class); + Connection connection = mock(Connection.class); + PreparedStatement preparedStatement = mock(PreparedStatement.class); + ResultSet resultSet = mock(ResultSet.class); + ResultSetMetaData metaData = mock(ResultSetMetaData.class); + String sql = "select name from users"; + SqlExecutionTemplate template = new SqlExecutionTemplate(new SqlSafetyValidator()); + ReflectionTestUtils.setField(template, "maxRows", 1); + + when(dataSource.getConnection()).thenReturn(connection); + when(connection.prepareStatement(sql)).thenReturn(preparedStatement); + when(preparedStatement.executeQuery()).thenReturn(resultSet); + when(resultSet.getMetaData()).thenReturn(metaData); + when(metaData.getColumnCount()).thenReturn(1); + when(metaData.getColumnLabel(1)).thenReturn("name"); + when(resultSet.next()).thenReturn(true, true, false); + when(resultSet.getObject(1)).thenReturn("Alice"); + + try (MockedStatic dataSourceFactory = mockStatic(DataSourceFactory.class)) { + dataSourceFactory.when(() -> DataSourceFactory.getDataSource("biz")).thenReturn(dataSource); + + BusinessQueryResult result = template.query("biz", sql); + + assertEquals(1, result.getRowCount()); + assertTrue(result.isTruncated()); + assertEquals(1, result.getMaxRows()); + } + verify(preparedStatement).setMaxRows(2); + } + + @Test + void shouldRejectDmlAndDdlSql() { + SqlExecutionTemplate template = new SqlExecutionTemplate(new SqlSafetyValidator()); + + assertThrows(StoreException.class, () -> template.query("biz", "delete from users where id = 1")); + assertThrows(StoreException.class, () -> template.query("biz", "drop table users")); + } + + @Test + void shouldRejectMultipleStatements() { + SqlExecutionTemplate template = new SqlExecutionTemplate(new SqlSafetyValidator()); + + StoreException exception = + assertThrows(StoreException.class, () -> template.query("biz", "select 1; select 2")); + + assertEquals("Only a single SELECT statement is allowed", exception.getMessage()); + } + + @Test + void shouldRejectSelectForUpdate() { + SqlExecutionTemplate template = new SqlExecutionTemplate(new SqlSafetyValidator()); + + StoreException exception = + assertThrows(StoreException.class, () -> template.query("biz", "select * from users for update")); + + assertEquals("SELECT locking clauses are not allowed", exception.getMessage()); + } + + @Test + void shouldRejectUndoLogQueryCaseInsensitive() { + SqlExecutionTemplate template = new SqlExecutionTemplate(new SqlSafetyValidator()); + + StoreException exception = + assertThrows(StoreException.class, () -> template.query("biz", "select * from `Undo_Log`")); - assertEquals( - "The query valid failed,Only query operations are allowed:delete from users where id = 1", - exception.getMessage()); + assertEquals("Querying undo_log is not allowed", exception.getMessage()); } } diff --git a/console/src/test/java/org/apache/seata/mcp/tools/BusinessDataSourceToolsTest.java b/console/src/test/java/org/apache/seata/mcp/tools/BusinessDataSourceToolsTest.java new file mode 100644 index 00000000000..66c942fc2f9 --- /dev/null +++ b/console/src/test/java/org/apache/seata/mcp/tools/BusinessDataSourceToolsTest.java @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.tools; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo; +import org.apache.seata.mcp.service.BusinessDataSourceService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class BusinessDataSourceToolsTest { + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void shouldRejectDynamicRegistrationForNonAdminAuthority() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + BusinessDataSourceTools tools = new BusinessDataSourceTools(service, new ObjectMapper()); + TestingAuthenticationToken authentication = new TestingAuthenticationToken("user", "pwd", "ROLE_USER"); + SecurityContextHolder.getContext().setAuthentication(authentication); + + assertThrows( + AccessDeniedException.class, () -> tools.registerMysqlDataSource(new MysqlDataSourceRegisterRequest())); + } + + @Test + void shouldAllowDynamicRegistrationForAdminAuthority() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + BusinessDataSourceTools tools = new BusinessDataSourceTools(service, new ObjectMapper()); + MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); + TestingAuthenticationToken authentication = new TestingAuthenticationToken("admin", "pwd", "ROLE_ADMIN"); + SecurityContextHolder.getContext().setAuthentication(authentication); + when(service.registerMysqlDataSource(request)).thenReturn("business-ds://biz"); + + tools.registerMysqlDataSource(request); + + verify(service).registerMysqlDataSource(request); + } + + @Test + void shouldSerializeDataSourceResourceWithoutSensitiveFields() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + BusinessDataSourceTools tools = new BusinessDataSourceTools(service, new ObjectMapper()); + MysqlDataSourceInfo info = new MysqlDataSourceInfo(); + info.setName("biz"); + info.setResourceId("business-ds://biz"); + info.setDatasource("druid"); + when(service.getMysqlDataSources()).thenReturn(Collections.singletonList(info)); + + String json = tools.mysqlDataSourcesResource(); + + assertFalse(json.contains("jdbc:mysql://")); + assertFalse(json.contains("username")); + assertFalse(json.contains("password")); + } +} From 86b837e1c22bb1b765a4bf8b62655dd34ccd2f9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Thu, 4 Jun 2026 00:36:19 +0800 Subject: [PATCH 26/43] refactor: simplify datasource mcp tool names --- .../mcp/tools/BusinessDataSourceTools.java | 44 +++++++++++-------- .../tools/BusinessDataSourceToolsTest.java | 39 ++++++++++++++-- 2 files changed, 61 insertions(+), 22 deletions(-) diff --git a/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java b/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java index 913c5c500ae..3857c9045f1 100644 --- a/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java +++ b/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java @@ -56,8 +56,8 @@ public BusinessDataSourceTools(BusinessDataSourceService dataSourceService, Obje this.objectMapper = objectMapper; } - @McpTool(description = "Register a dynamic MySQL business data source. Admin only.") - public String registerMysqlDataSource( + @McpTool(name = "registerDataSource", description = "Register a dynamic MySQL business data source. Admin only.") + public String registerDataSource( @McpToolParam(description = "MySQL data source registration request", required = true) MysqlDataSourceRegisterRequest request) { requireAdmin(); @@ -65,16 +65,20 @@ public String registerMysqlDataSource( return dataSourceService.registerMysqlDataSource(request); } - @McpTool(description = "Unregister a dynamic MySQL business data source. Admin only.") - public String unregisterMysqlDataSource( + @McpTool( + name = "unregisterDataSource", + description = "Unregister a dynamic MySQL business data source. Admin only.") + public String unregisterDataSource( @McpToolParam(description = "The data source name", required = true) String name) { requireAdmin(); LOGGER.info("User tries to unregister MySQL business data source: {}", name); return dataSourceService.unregisterMysqlDataSource(name); } - @McpTool(description = "Test a MySQL business data source registration request. Admin only.") - public MysqlDataSourceTestResult testMysqlDataSource( + @McpTool( + name = "testDataSource", + description = "Test a MySQL business data source registration request. Admin only.") + public MysqlDataSourceTestResult testDataSource( @McpToolParam(description = "MySQL data source registration request", required = true) MysqlDataSourceRegisterRequest request) { requireAdmin(); @@ -82,14 +86,14 @@ public MysqlDataSourceTestResult testMysqlDataSource( return dataSourceService.testMysqlDataSource(request); } - @McpTool(description = "Get all MySQL business data sources without sensitive fields") - public List getMysqlDataSources() { + @McpTool(name = "getDataSources", description = "Get all MySQL business data sources without sensitive fields") + public List getDataSources() { LOGGER.info("User tries to get MySQL business data sources"); return dataSourceService.getMysqlDataSources(); } - @McpTool(description = "List MySQL schemas in a business data source") - public List listMysqlSchemas( + @McpTool(name = "listSchemas", description = "List MySQL schemas in a business data source") + public List listSchemas( @McpToolParam( description = "The identity of the data source, for example business-ds://biz", required = true) @@ -98,8 +102,8 @@ public List listMysqlSchemas( return dataSourceService.listMysqlSchemas(resourceId); } - @McpTool(description = "Get MySQL table names in a schema") - public List getMysqlTableNames( + @McpTool(name = "getTableNames", description = "Get MySQL table names in a schema") + public List getTableNames( @McpToolParam( description = "The identity of the data source, for example business-ds://biz", required = true) @@ -109,8 +113,8 @@ public List getMysqlTableNames( return dataSourceService.getMysqlTableNames(resourceId, schemaName); } - @McpTool(description = "Get MySQL table columns in a schema") - public List getMysqlTableSchema( + @McpTool(name = "getTableSchema", description = "Get MySQL table columns in a schema") + public List getTableSchema( @McpToolParam( description = "The identity of the data source, for example business-ds://biz", required = true) @@ -125,8 +129,10 @@ public List getMysqlTableSchema( return dataSourceService.getMysqlTableSchema(resourceId, schemaName, tableName); } - @McpTool(description = "Query a MySQL table with optional column list, equality filters, and row limit") - public BusinessQueryResult queryMysqlTable( + @McpTool( + name = "queryTable", + description = "Query a MySQL table with optional column list, equality filters, and row limit") + public BusinessQueryResult queryTable( @McpToolParam( description = "The identity of the data source, for example business-ds://biz", required = true) @@ -145,8 +151,8 @@ public BusinessQueryResult queryMysqlTable( return dataSourceService.queryMysqlTable(resourceId, schemaName, tableName, columns, filters, limit); } - @McpTool(description = "Explain a safe MySQL SELECT SQL statement") - public BusinessQueryResult explainMysqlSql( + @McpTool(name = "explainSql", description = "Explain a safe MySQL SELECT SQL statement") + public BusinessQueryResult explainSql( @McpToolParam( description = "The identity of the data source, for example business-ds://biz", required = true) @@ -156,7 +162,7 @@ public BusinessQueryResult explainMysqlSql( return dataSourceService.explainMysqlSql(resourceId, sql); } - @McpTool(description = "Execute a safe MySQL SELECT query against a business data source") + @McpTool(name = "runSql", description = "Execute a safe MySQL SELECT query against a business data source") public BusinessQueryResult runSql( @McpToolParam(description = "MySQL SELECT SQL statement", required = true) String sql, @McpToolParam( diff --git a/console/src/test/java/org/apache/seata/mcp/tools/BusinessDataSourceToolsTest.java b/console/src/test/java/org/apache/seata/mcp/tools/BusinessDataSourceToolsTest.java index 66c942fc2f9..bd1dde7f226 100644 --- a/console/src/test/java/org/apache/seata/mcp/tools/BusinessDataSourceToolsTest.java +++ b/console/src/test/java/org/apache/seata/mcp/tools/BusinessDataSourceToolsTest.java @@ -22,12 +22,19 @@ import org.apache.seata.mcp.service.BusinessDataSourceService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpTool; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; +import java.lang.reflect.Method; +import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; @@ -48,8 +55,7 @@ void shouldRejectDynamicRegistrationForNonAdminAuthority() { TestingAuthenticationToken authentication = new TestingAuthenticationToken("user", "pwd", "ROLE_USER"); SecurityContextHolder.getContext().setAuthentication(authentication); - assertThrows( - AccessDeniedException.class, () -> tools.registerMysqlDataSource(new MysqlDataSourceRegisterRequest())); + assertThrows(AccessDeniedException.class, () -> tools.registerDataSource(new MysqlDataSourceRegisterRequest())); } @Test @@ -61,11 +67,33 @@ void shouldAllowDynamicRegistrationForAdminAuthority() { SecurityContextHolder.getContext().setAuthentication(authentication); when(service.registerMysqlDataSource(request)).thenReturn("business-ds://biz"); - tools.registerMysqlDataSource(request); + tools.registerDataSource(request); verify(service).registerMysqlDataSource(request); } + @Test + void shouldExposeToolNamesWithoutMysqlPrefix() { + Set toolNames = Arrays.stream(BusinessDataSourceTools.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(McpTool.class)) + .map(this::toolName) + .collect(Collectors.toSet()); + + assertEquals( + new HashSet<>(Arrays.asList( + "registerDataSource", + "unregisterDataSource", + "testDataSource", + "getDataSources", + "listSchemas", + "getTableNames", + "getTableSchema", + "queryTable", + "explainSql", + "runSql")), + toolNames); + } + @Test void shouldSerializeDataSourceResourceWithoutSensitiveFields() { BusinessDataSourceService service = mock(BusinessDataSourceService.class); @@ -82,4 +110,9 @@ void shouldSerializeDataSourceResourceWithoutSensitiveFields() { assertFalse(json.contains("username")); assertFalse(json.contains("password")); } + + private String toolName(Method method) { + McpTool annotation = method.getAnnotation(McpTool.class); + return annotation.name().isEmpty() ? method.getName() : annotation.name(); + } } From dbb8a16348fe70c9bc88a474de0539e2564f4cdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Thu, 4 Jun 2026 01:19:18 +0800 Subject: [PATCH 27/43] build: compile console with java 25 --- console/pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/console/pom.xml b/console/pom.xml index a8764539696..b849a858a97 100644 --- a/console/pom.xml +++ b/console/pom.xml @@ -32,6 +32,7 @@ console for Seata built with Maven + 25 3.5.2 6.2.8 2.0 From 9adfdd854a4d4aa628bfb06917cf3ac60bff9b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Thu, 4 Jun 2026 01:19:35 +0800 Subject: [PATCH 28/43] refactor: scope datasource tools to url database --- .../seata/mcp/core/constant/SqlConstant.java | 3 - .../props/BusinessDataSourcesProperties.java | 81 ++++++++++++------- .../dto/MysqlDataSourceRegisterRequest.java | 12 --- .../mcp/entity/vo/MysqlDataSourceInfo.java | 21 +++-- .../service/BusinessDataSourceService.java | 13 +-- .../mcp/service/MysqlMetadataService.java | 29 ++----- .../impl/BusinessDataSourceServiceImpl.java | 40 ++++----- .../seata/mcp/store/SqlSafetyValidator.java | 30 +++++++ .../mcp/tools/BusinessDataSourceTools.java | 66 +++++---------- .../BusinessDataSourcesPropertiesTest.java | 28 ++++++- .../mcp/service/MysqlMetadataServiceTest.java | 7 +- .../BusinessDataSourceServiceImplTest.java | 29 ++++++- .../mcp/store/SqlExecutionTemplateTest.java | 18 +++++ .../tools/BusinessDataSourceToolsTest.java | 1 - 14 files changed, 208 insertions(+), 170 deletions(-) diff --git a/console/src/main/java/org/apache/seata/mcp/core/constant/SqlConstant.java b/console/src/main/java/org/apache/seata/mcp/core/constant/SqlConstant.java index 1209b0020e7..785f365befa 100644 --- a/console/src/main/java/org/apache/seata/mcp/core/constant/SqlConstant.java +++ b/console/src/main/java/org/apache/seata/mcp/core/constant/SqlConstant.java @@ -18,9 +18,6 @@ public class SqlConstant { - public static final String LIST_SCHEMA_SQL = - "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA ORDER BY SCHEMA_NAME"; - public static final String GET_TABLE_NAME_SQL = "SELECT TABLE_NAME, TABLE_COMMENT FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? " + "ORDER BY TABLE_NAME"; diff --git a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java index f30d46bc93c..bf7f08110b7 100644 --- a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java +++ b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java @@ -31,7 +31,6 @@ import org.springframework.stereotype.Component; import java.net.URI; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; @@ -82,6 +81,9 @@ public class BusinessDataSourcesProperties implements InitializingBean { private static final Pattern DATASOURCE_NAME_PATTERN = Pattern.compile("[A-Za-z0-9_.-]+"); + private static final Set SYSTEM_DATABASES = + new HashSet<>(Arrays.asList("information_schema", "mysql", "performance_schema", "sys")); + private static final Logger LOGGER = LoggerFactory.getLogger(BusinessDataSourcesProperties.class); public BusinessDataSourcesProperties(Environment env, ObjectMapper objectMapper, SecretResolver secretResolver) { @@ -121,7 +123,6 @@ public void afterPropertiesSet() { props.setMinConn(env.getProperty(prefix + "minConn", Integer.class, DEFAULT_DB_MIN_CONN)); props.setMaxConn(env.getProperty(prefix + "maxConn", Integer.class, DEFAULT_DB_MAX_CONN)); props.setMaxWait(env.getProperty(prefix + "maxWait", Long.class, 5000L)); - props.setAllowedSchemas(parseAllowedSchemas(env.getProperty(prefix + "allowedSchemas", ""))); if (!validateDataSourceProperties(props, name)) { continue; } @@ -171,11 +172,10 @@ public synchronized DataSourceProperties buildDynamicMysqlProperties(MysqlDataSo props.setMinConn(request.getMinConn() <= 0 ? DEFAULT_DB_MIN_CONN : request.getMinConn()); props.setMaxConn(request.getMaxConn() <= 0 ? DEFAULT_DB_MAX_CONN : request.getMaxConn()); props.setMaxWait(request.getMaxWait() == null ? 5000L : request.getMaxWait()); - props.setAllowedSchemas(request.getAllowedSchemas()); + validateDynamicMysqlProperties(props); if (!validateDataSourceProperties(props, props.getName())) { throw new IllegalArgumentException("Business DataSource Properties has failure"); } - validateDynamicMysqlProperties(props); return props; } @@ -203,12 +203,12 @@ public List getMysqlDataSourceInfos() { .collect(Collectors.toList()); } - public boolean isAllowedSchema(String resourceId, String schemaName) { + public String getDatabaseName(String resourceId) { DataSourceProperties props = datasources.get(resourceId); - if (props == null || props.getAllowedSchemas().isEmpty()) { - return true; + if (props == null) { + throw new IllegalArgumentException("Cannot find datasource properties: " + resourceId); } - return props.getAllowedSchemas().stream().anyMatch(schema -> schema.equalsIgnoreCase(schemaName)); + return props.getDatabaseName(); } private MysqlDataSourceInfo toInfo(String name, DataSourceProperties props) { @@ -218,10 +218,10 @@ private MysqlDataSourceInfo toInfo(String name, DataSourceProperties props) { MysqlDataSourceInfo info = new MysqlDataSourceInfo(); info.setName(name); info.setResourceId(props.getResourceId()); + info.setDatabaseName(props.getDatabaseName()); info.setDatasource(props.getDatasource()); info.setDynamic(props.isDynamic()); info.setEnabled(props.isEnabled()); - info.setAllowedSchemas(props.getAllowedSchemas()); return info; } @@ -268,6 +268,12 @@ private boolean validateDataSourceProperties(DataSourceProperties props, String LOGGER.error("Only MySQL 8 driver is supported for datasource: {}", dataSourceName); return false; } + try { + props.setDatabaseName(parseMysqlDatabaseName(props.getUrl())); + } catch (IllegalArgumentException e) { + LOGGER.error("Invalid MySQL JDBC URL for datasource: {}", dataSourceName); + return false; + } if (props.getMinConn() < 0) { LOGGER.error("Minimum connection count cannot be negative for datasource: {}", dataSourceName); return false; @@ -297,6 +303,7 @@ private void validateDynamicMysqlProperties(DataSourceProperties props) { if (!allowedHosts.isEmpty() && !allowedHosts.contains(host.toLowerCase(Locale.ROOT))) { throw new IllegalArgumentException("MySQL host is not allowed: " + host); } + props.setDatabaseName(parseMysqlDatabaseName(props.getUrl())); } private String parseMysqlHost(String url) { @@ -311,6 +318,34 @@ private String parseMysqlHost(String url) { } } + private String parseMysqlDatabaseName(String url) { + if (!StringUtils.hasText(url) || !url.toLowerCase(Locale.ROOT).startsWith("jdbc:mysql://")) { + throw new IllegalArgumentException("Only jdbc:mysql:// URL is supported"); + } + try { + URI uri = URI.create(url.substring("jdbc:".length())); + String path = uri.getPath(); + if (!StringUtils.hasText(path) || "/".equals(path)) { + throw new IllegalArgumentException("MySQL JDBC URL must include a database name"); + } + String databaseName = path.startsWith("/") ? path.substring(1) : path; + int slashIndex = databaseName.indexOf('/'); + if (slashIndex >= 0) { + databaseName = databaseName.substring(0, slashIndex); + } + if (!StringUtils.hasText(databaseName)) { + throw new IllegalArgumentException("MySQL JDBC URL must include a database name"); + } + String normalized = databaseName.toLowerCase(Locale.ROOT); + if (SYSTEM_DATABASES.contains(normalized)) { + throw new IllegalArgumentException("MySQL JDBC URL database is not allowed: " + databaseName); + } + return databaseName; + } catch (IllegalArgumentException e) { + throw e; + } + } + private Set parseAllowedHosts(String hosts) { if (!StringUtils.hasText(hosts)) { return Collections.emptySet(); @@ -322,16 +357,6 @@ private Set parseAllowedHosts(String hosts) { .collect(Collectors.toSet()); } - private List parseAllowedSchemas(String schemas) { - if (!StringUtils.hasText(schemas)) { - return Collections.emptyList(); - } - return Arrays.stream(schemas.split(",")) - .map(String::trim) - .filter(StringUtils::isNotBlank) - .collect(Collectors.toList()); - } - private String buildResourceId(String name) { if (!StringUtils.hasText(name)) { throw new IllegalArgumentException("The data source name cannot be empty"); @@ -393,6 +418,7 @@ public static class DataSourceProperties { private boolean dynamic; private String name; private String resourceId; + private String databaseName; private String dbType = MYSQL_DB_TYPE; private String driverClassName = MYSQL_DRIVER_CLASS_NAME; private String url = ""; @@ -403,7 +429,6 @@ public static class DataSourceProperties { private int minConn = DEFAULT_DB_MIN_CONN; private int maxConn = DEFAULT_DB_MAX_CONN; private Long maxWait = 5000L; - private List allowedSchemas = new ArrayList<>(); public boolean isEnabled() { return enabled; @@ -437,6 +462,14 @@ public void setResourceId(String resourceId) { this.resourceId = resourceId; } + public String getDatabaseName() { + return databaseName; + } + + public void setDatabaseName(String databaseName) { + this.databaseName = databaseName; + } + public Long getMaxWait() { return maxWait; } @@ -516,13 +549,5 @@ public int getMaxConn() { public void setMaxConn(int maxConn) { this.maxConn = maxConn; } - - public List getAllowedSchemas() { - return allowedSchemas; - } - - public void setAllowedSchemas(List allowedSchemas) { - this.allowedSchemas = allowedSchemas == null ? Collections.emptyList() : allowedSchemas; - } } } diff --git a/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java b/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java index e8e9009b219..e11b3235c29 100644 --- a/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java +++ b/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java @@ -16,9 +16,6 @@ */ package org.apache.seata.mcp.entity.dto; -import java.util.Collections; -import java.util.List; - public class MysqlDataSourceRegisterRequest { private String name; @@ -30,7 +27,6 @@ public class MysqlDataSourceRegisterRequest { private int minConn = 10; private int maxConn = 100; private Long maxWait = 5000L; - private List allowedSchemas = Collections.emptyList(); public String getName() { return name; @@ -103,12 +99,4 @@ public Long getMaxWait() { public void setMaxWait(Long maxWait) { this.maxWait = maxWait; } - - public List getAllowedSchemas() { - return allowedSchemas; - } - - public void setAllowedSchemas(List allowedSchemas) { - this.allowedSchemas = allowedSchemas == null ? Collections.emptyList() : allowedSchemas; - } } diff --git a/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlDataSourceInfo.java b/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlDataSourceInfo.java index a5110d0f6a6..16ca4dcabc3 100644 --- a/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlDataSourceInfo.java +++ b/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlDataSourceInfo.java @@ -16,17 +16,14 @@ */ package org.apache.seata.mcp.entity.vo; -import java.util.Collections; -import java.util.List; - public class MysqlDataSourceInfo { private String name; private String resourceId; + private String databaseName; private String datasource; private boolean dynamic; private boolean enabled; - private List allowedSchemas = Collections.emptyList(); public String getName() { return name; @@ -44,6 +41,14 @@ public void setResourceId(String resourceId) { this.resourceId = resourceId; } + public String getDatabaseName() { + return databaseName; + } + + public void setDatabaseName(String databaseName) { + this.databaseName = databaseName; + } + public String getDatasource() { return datasource; } @@ -67,12 +72,4 @@ public boolean isEnabled() { public void setEnabled(boolean enabled) { this.enabled = enabled; } - - public List getAllowedSchemas() { - return allowedSchemas; - } - - public void setAllowedSchemas(List allowedSchemas) { - this.allowedSchemas = allowedSchemas == null ? Collections.emptyList() : allowedSchemas; - } } diff --git a/console/src/main/java/org/apache/seata/mcp/service/BusinessDataSourceService.java b/console/src/main/java/org/apache/seata/mcp/service/BusinessDataSourceService.java index 9432676760a..03451d61267 100644 --- a/console/src/main/java/org/apache/seata/mcp/service/BusinessDataSourceService.java +++ b/console/src/main/java/org/apache/seata/mcp/service/BusinessDataSourceService.java @@ -35,21 +35,14 @@ public interface BusinessDataSourceService { MysqlDataSourceTestResult testMysqlDataSource(MysqlDataSourceRegisterRequest request); - List listMysqlSchemas(String resourceId); + List getMysqlTableNames(String resourceId); - List getMysqlTableNames(String resourceId, String schemaName); - - List getMysqlTableSchema(String resourceId, String schemaName, String tableName); + List getMysqlTableSchema(String resourceId, String tableName); BusinessQueryResult runSql(String sql, String resourceId); BusinessQueryResult queryMysqlTable( - String resourceId, - String schemaName, - String tableName, - List columns, - Map filters, - Integer limit); + String resourceId, String tableName, List columns, Map filters, Integer limit); BusinessQueryResult explainMysqlSql(String resourceId, String sql); } diff --git a/console/src/main/java/org/apache/seata/mcp/service/MysqlMetadataService.java b/console/src/main/java/org/apache/seata/mcp/service/MysqlMetadataService.java index 83b62be0f30..b16d0d92344 100644 --- a/console/src/main/java/org/apache/seata/mcp/service/MysqlMetadataService.java +++ b/console/src/main/java/org/apache/seata/mcp/service/MysqlMetadataService.java @@ -49,29 +49,23 @@ public MysqlMetadataService( this.businessDataSourcesProperties = businessDataSourcesProperties; } - public List listSchemas(String resourceId) { - return sqlExecutionTemplate.trustedQuery(resourceId, SqlConstant.LIST_SCHEMA_SQL).getRows().stream() - .map(row -> String.valueOf(row.get("SCHEMA_NAME"))) - .collect(Collectors.toList()); - } - - public List listTables(String resourceId, String schemaName) { - validateAllowedSchema(resourceId, schemaName); + public List listTables(String resourceId) { + String databaseName = businessDataSourcesProperties.getDatabaseName(resourceId); return sqlExecutionTemplate - .trustedQuery(resourceId, SqlConstant.GET_TABLE_NAME_SQL, schemaName) + .trustedQuery(resourceId, SqlConstant.GET_TABLE_NAME_SQL, databaseName) .getRows() .stream() .map(this::toTableInfo) .collect(Collectors.toList()); } - public List describeTable(String resourceId, String schemaName, String tableName) { - validateAllowedSchema(resourceId, schemaName); + public List describeTable(String resourceId, String tableName) { + String databaseName = businessDataSourcesProperties.getDatabaseName(resourceId); if (!StringUtils.hasText(tableName)) { throw new StoreException("tableName cannot be empty"); } return sqlExecutionTemplate - .trustedQuery(resourceId, SqlConstant.GET_SCHEMA_SQL, schemaName, tableName) + .trustedQuery(resourceId, SqlConstant.GET_SCHEMA_SQL, databaseName, tableName) .getRows() .stream() .map(this::toColumnInfo) @@ -79,19 +73,10 @@ public List describeTable(String resourceId, String schemaName, } public BusinessQueryResult explainSql(String resourceId, String sql) { - sqlSafetyValidator.validateMysqlSelect(sql); + sqlSafetyValidator.validateMysqlSelect(sql, businessDataSourcesProperties.getDatabaseName(resourceId)); return sqlExecutionTemplate.trustedQuery(resourceId, SqlConstant.MYSQL_EXPLAIN_PREFIX + sql); } - private void validateAllowedSchema(String resourceId, String schemaName) { - if (!StringUtils.hasText(schemaName)) { - throw new StoreException("schemaName cannot be empty"); - } - if (!businessDataSourcesProperties.isAllowedSchema(resourceId, schemaName)) { - throw new StoreException("schemaName is not allowed: " + schemaName); - } - } - private MysqlTableInfo toTableInfo(Map row) { MysqlTableInfo info = new MysqlTableInfo(); info.setTableName(String.valueOf(row.get("TABLE_NAME"))); diff --git a/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java b/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java index 30ce8583726..8f2859e1cb0 100644 --- a/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java +++ b/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java @@ -30,6 +30,7 @@ import org.apache.seata.mcp.service.MysqlMetadataService; import org.apache.seata.mcp.store.DataSourceFactory; import org.apache.seata.mcp.store.SqlExecutionTemplate; +import org.apache.seata.mcp.store.SqlSafetyValidator; import org.springframework.stereotype.Service; import java.sql.Connection; @@ -51,13 +52,17 @@ public class BusinessDataSourceServiceImpl implements BusinessDataSourceService private final MysqlMetadataService mysqlMetadataService; + private final SqlSafetyValidator sqlSafetyValidator; + public BusinessDataSourceServiceImpl( SqlExecutionTemplate sqlExecutionTemplate, BusinessDataSourcesProperties businessDataSourcesProperties, - MysqlMetadataService mysqlMetadataService) { + MysqlMetadataService mysqlMetadataService, + SqlSafetyValidator sqlSafetyValidator) { this.sqlExecutionTemplate = sqlExecutionTemplate; this.businessDataSourcesProperties = businessDataSourcesProperties; this.mysqlMetadataService = mysqlMetadataService; + this.sqlSafetyValidator = sqlSafetyValidator; } @Override @@ -103,35 +108,26 @@ public MysqlDataSourceTestResult testMysqlDataSource(MysqlDataSourceRegisterRequ } @Override - public List listMysqlSchemas(String resourceId) { - return mysqlMetadataService.listSchemas(resourceId); - } - - @Override - public List getMysqlTableNames(String resourceId, String schemaName) { - return mysqlMetadataService.listTables(resourceId, schemaName); + public List getMysqlTableNames(String resourceId) { + return mysqlMetadataService.listTables(resourceId); } @Override - public List getMysqlTableSchema(String resourceId, String schemaName, String tableName) { - return mysqlMetadataService.describeTable(resourceId, schemaName, tableName); + public List getMysqlTableSchema(String resourceId, String tableName) { + return mysqlMetadataService.describeTable(resourceId, tableName); } @Override public BusinessQueryResult runSql(String sql, String resourceId) { + sqlSafetyValidator.validateMysqlSelect(sql, businessDataSourcesProperties.getDatabaseName(resourceId)); return sqlExecutionTemplate.query(resourceId, sql); } @Override public BusinessQueryResult queryMysqlTable( - String resourceId, - String schemaName, - String tableName, - List columns, - Map filters, - Integer limit) { - validateAllowedSchema(resourceId, schemaName); - validateIdentifier("schemaName", schemaName); + String resourceId, String tableName, List columns, Map filters, Integer limit) { + String databaseName = businessDataSourcesProperties.getDatabaseName(resourceId); + validateIdentifier("databaseName", databaseName); validateIdentifier("tableName", tableName); StringBuilder sql = new StringBuilder("SELECT "); @@ -140,7 +136,7 @@ public BusinessQueryResult queryMysqlTable( } else { sql.append(buildColumnList(columns)); } - sql.append(" FROM ").append(quote(schemaName)).append(".").append(quote(tableName)); + sql.append(" FROM ").append(quote(databaseName)).append(".").append(quote(tableName)); List params = new ArrayList<>(); if (filters != null && !filters.isEmpty()) { @@ -166,12 +162,6 @@ public BusinessQueryResult explainMysqlSql(String resourceId, String sql) { return mysqlMetadataService.explainSql(resourceId, sql); } - private void validateAllowedSchema(String resourceId, String schemaName) { - if (!businessDataSourcesProperties.isAllowedSchema(resourceId, schemaName)) { - throw new StoreException("schemaName is not allowed: " + schemaName); - } - } - private String buildColumnList(List columns) { StringBuilder builder = new StringBuilder(); for (String column : columns) { diff --git a/console/src/main/java/org/apache/seata/mcp/store/SqlSafetyValidator.java b/console/src/main/java/org/apache/seata/mcp/store/SqlSafetyValidator.java index 626736eb5a2..aca97fdf5fd 100644 --- a/console/src/main/java/org/apache/seata/mcp/store/SqlSafetyValidator.java +++ b/console/src/main/java/org/apache/seata/mcp/store/SqlSafetyValidator.java @@ -63,6 +63,12 @@ public SQLSelectStatement validateMysqlSelect(String sql) { return selectStatement; } + public SQLSelectStatement validateMysqlSelect(String sql, String databaseName) { + SQLSelectStatement selectStatement = validateMysqlSelect(sql); + validateDatabaseScope(selectStatement, databaseName); + return selectStatement; + } + private boolean hasLockClause(SQLSelectStatement statement) { final boolean[] result = new boolean[] {false}; statement.accept(new SQLASTVisitorAdapter() { @@ -89,6 +95,26 @@ private boolean containsUndoLog(SQLSelectStatement statement) { return false; } + private void validateDatabaseScope(SQLSelectStatement statement, String databaseName) { + if (StringUtils.isBlank(databaseName)) { + throw new StoreException("Database name cannot be empty"); + } + SchemaStatVisitor visitor = SQLUtils.createSchemaStatVisitor(DbType.mysql); + statement.accept(visitor); + String normalizedDatabase = normalizeIdentifier(databaseName); + for (TableStat.Name name : visitor.getTables().keySet()) { + String tableName = name.getName(); + int index = tableName.lastIndexOf('.'); + if (index < 0) { + continue; + } + String queriedDatabaseName = normalizeIdentifier(tableName.substring(0, index)); + if (!normalizedDatabase.equals(queriedDatabaseName)) { + throw new StoreException("Cross-database query is not allowed"); + } + } + } + private String normalizeTableName(String tableName) { String normalized = tableName; int index = normalized.lastIndexOf('.'); @@ -97,4 +123,8 @@ private String normalizeTableName(String tableName) { } return normalized.replace("`", "").replace("\"", "").toLowerCase(Locale.ROOT); } + + private String normalizeIdentifier(String identifier) { + return identifier.replace("`", "").replace("\"", "").toLowerCase(Locale.ROOT); + } } diff --git a/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java b/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java index 3857c9045f1..a844184dacc 100644 --- a/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java +++ b/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java @@ -92,63 +92,43 @@ public List getDataSources() { return dataSourceService.getMysqlDataSources(); } - @McpTool(name = "listSchemas", description = "List MySQL schemas in a business data source") - public List listSchemas( - @McpToolParam( - description = "The identity of the data source, for example business-ds://biz", - required = true) - String resourceId) { - LOGGER.info("User tries to list MySQL schemas, resourceId: {}", resourceId); - return dataSourceService.listMysqlSchemas(resourceId); - } - - @McpTool(name = "getTableNames", description = "Get MySQL table names in a schema") + @McpTool(name = "getTableNames", description = "Get table names in the data source database") public List getTableNames( @McpToolParam( description = "The identity of the data source, for example business-ds://biz", required = true) - String resourceId, - @McpToolParam(description = "MySQL schema name", required = true) String schemaName) { - LOGGER.info("User tries to get MySQL table names, resourceId: {}, schemaName: {}", resourceId, schemaName); - return dataSourceService.getMysqlTableNames(resourceId, schemaName); + String resourceId) { + LOGGER.info("User tries to get MySQL table names, resourceId: {}", resourceId); + return dataSourceService.getMysqlTableNames(resourceId); } - @McpTool(name = "getTableSchema", description = "Get MySQL table columns in a schema") + @McpTool(name = "getTableSchema", description = "Get table columns in the data source database") public List getTableSchema( @McpToolParam( description = "The identity of the data source, for example business-ds://biz", required = true) String resourceId, - @McpToolParam(description = "MySQL schema name", required = true) String schemaName, @McpToolParam(description = "MySQL table name", required = true) String tableName) { - LOGGER.info( - "User tries to get MySQL table schema, resourceId: {}, schemaName: {}, tableName: {}", - resourceId, - schemaName, - tableName); - return dataSourceService.getMysqlTableSchema(resourceId, schemaName, tableName); + LOGGER.info("User tries to get MySQL table schema, resourceId: {}, tableName: {}", resourceId, tableName); + return dataSourceService.getMysqlTableSchema(resourceId, tableName); } @McpTool( name = "queryTable", - description = "Query a MySQL table with optional column list, equality filters, and row limit") + description = + "Query a table in the data source database with optional column list, equality filters, and row limit") public BusinessQueryResult queryTable( @McpToolParam( description = "The identity of the data source, for example business-ds://biz", required = true) String resourceId, - @McpToolParam(description = "MySQL schema name", required = true) String schemaName, @McpToolParam(description = "MySQL table name", required = true) String tableName, @McpToolParam(description = "Column names to select", required = false) List columns, @McpToolParam(description = "Equality filters keyed by column name", required = false) Map filters, @McpToolParam(description = "Maximum rows to return", required = false) Integer limit) { - LOGGER.info( - "User tries to query MySQL table, resourceId: {}, schemaName: {}, tableName: {}", - resourceId, - schemaName, - tableName); - return dataSourceService.queryMysqlTable(resourceId, schemaName, tableName, columns, filters, limit); + LOGGER.info("User tries to query MySQL table, resourceId: {}, tableName: {}", resourceId, tableName); + return dataSourceService.queryMysqlTable(resourceId, tableName, columns, filters, limit); } @McpTool(name = "explainSql", description = "Explain a safe MySQL SELECT SQL statement") @@ -183,34 +163,24 @@ public String mysqlDataSourcesResource() { return toJson(dataSourceService.getMysqlDataSources()); } - @McpResource( - name = "mysqlSchemas", - title = "MySQL schemas", - uri = "mysql-db://{resourceId}/schemas", - description = "MySQL schema list for a business data source", - mimeType = "application/json") - public String mysqlSchemasResource(String resourceId) { - return toJson(dataSourceService.listMysqlSchemas(resourceId)); - } - @McpResource( name = "mysqlTables", title = "MySQL tables", - uri = "mysql-db://{resourceId}/{schemaName}/tables", - description = "MySQL table list for a business schema", + uri = "mysql-db://{resourceId}/tables", + description = "MySQL table list for a business data source database", mimeType = "application/json") - public String mysqlTablesResource(String resourceId, String schemaName) { - return toJson(dataSourceService.getMysqlTableNames(resourceId, schemaName)); + public String mysqlTablesResource(String resourceId) { + return toJson(dataSourceService.getMysqlTableNames(resourceId)); } @McpResource( name = "mysqlTableSchema", title = "MySQL table schema", - uri = "mysql-db://{resourceId}/{schemaName}/{tableName}/schema", + uri = "mysql-db://{resourceId}/{tableName}/schema", description = "MySQL column list for a business table", mimeType = "application/json") - public String mysqlTableSchemaResource(String resourceId, String schemaName, String tableName) { - return toJson(dataSourceService.getMysqlTableSchema(resourceId, schemaName, tableName)); + public String mysqlTableSchemaResource(String resourceId, String tableName) { + return toJson(dataSourceService.getMysqlTableSchema(resourceId, tableName)); } private void requireAdmin() { diff --git a/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java b/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java index d0a2cc7a498..a33770f553b 100644 --- a/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java +++ b/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java @@ -24,8 +24,6 @@ import org.junit.jupiter.api.Test; import org.springframework.mock.env.MockEnvironment; -import java.util.Collections; - import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -64,6 +62,7 @@ void shouldResolvePasswordSecretRefAndRegisterMysqlResourceId() { BusinessDataSourcesProperties.getDatasources().get(resourceId); assertEquals("pwd", props.getPassword()); assertEquals("MYSQL_PASS", props.getPasswordSecretRef()); + assertEquals("app", props.getDatabaseName()); assertEquals( "business-ds://biz", BusinessDataSourcesProperties.getDataSourcesNamesAndResourceIds() @@ -95,6 +94,30 @@ void shouldRejectNonMysqlJdbcUrl() { assertEquals("Only jdbc:mysql:// URL is supported", exception.getMessage()); } + @Test + void shouldRejectMysqlJdbcUrlWithoutDatabaseName() { + BusinessDataSourcesProperties properties = newProperties(enabledEnv().withProperty("MYSQL_PASS", "pwd")); + MysqlDataSourceRegisterRequest request = request("biz", "localhost"); + request.setUrl("jdbc:mysql://localhost:3306"); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> properties.registerMysqlDataSource(request)); + + assertEquals("MySQL JDBC URL must include a database name", exception.getMessage()); + } + + @Test + void shouldRejectSystemDatabaseInMysqlJdbcUrl() { + BusinessDataSourcesProperties properties = newProperties(enabledEnv().withProperty("MYSQL_PASS", "pwd")); + MysqlDataSourceRegisterRequest request = request("biz", "localhost"); + request.setUrl("jdbc:mysql://localhost:3306/information_schema"); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> properties.registerMysqlDataSource(request)); + + assertEquals("MySQL JDBC URL database is not allowed: information_schema", exception.getMessage()); + } + @Test void shouldApplyHostAllowlist() { MockEnvironment env = enabledEnv() @@ -151,7 +174,6 @@ private MysqlDataSourceRegisterRequest request(String name, String host) { request.setPasswordSecretRef("MYSQL_PASS"); request.setMinConn(1); request.setMaxConn(2); - request.setAllowedSchemas(Collections.singletonList("app")); return request; } } diff --git a/console/src/test/java/org/apache/seata/mcp/service/MysqlMetadataServiceTest.java b/console/src/test/java/org/apache/seata/mcp/service/MysqlMetadataServiceTest.java index aad8ef7971a..3e1b30c889e 100644 --- a/console/src/test/java/org/apache/seata/mcp/service/MysqlMetadataServiceTest.java +++ b/console/src/test/java/org/apache/seata/mcp/service/MysqlMetadataServiceTest.java @@ -37,19 +37,18 @@ class MysqlMetadataServiceTest { @Test - void shouldQueryTablesWithSchemaParameter() { + void shouldQueryTablesWithUrlDatabaseName() { SqlExecutionTemplate sqlExecutionTemplate = mock(SqlExecutionTemplate.class); BusinessDataSourcesProperties properties = mock(BusinessDataSourcesProperties.class); BusinessQueryResult result = result(row("TABLE_NAME", "orders", "TABLE_COMMENT", "business orders")); - when(properties.isAllowedSchema("business-ds://biz", "app")).thenReturn(true); + when(properties.getDatabaseName("business-ds://biz")).thenReturn("app"); when(sqlExecutionTemplate.trustedQuery("business-ds://biz", SqlConstant.GET_TABLE_NAME_SQL, "app")) .thenReturn(result); MysqlMetadataService service = new MysqlMetadataService(sqlExecutionTemplate, new SqlSafetyValidator(), properties); - assertEquals( - "orders", service.listTables("business-ds://biz", "app").get(0).getTableName()); + assertEquals("orders", service.listTables("business-ds://biz").get(0).getTableName()); verify(sqlExecutionTemplate).trustedQuery("business-ds://biz", SqlConstant.GET_TABLE_NAME_SQL, "app"); } diff --git a/console/src/test/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImplTest.java b/console/src/test/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImplTest.java index ee61bf96708..5fdbacfa627 100644 --- a/console/src/test/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImplTest.java +++ b/console/src/test/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImplTest.java @@ -17,12 +17,17 @@ package org.apache.seata.mcp.service.impl; import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; +import org.apache.seata.mcp.entity.vo.BusinessQueryResult; import org.apache.seata.mcp.service.MysqlMetadataService; import org.apache.seata.mcp.store.DataSourceFactory; import org.apache.seata.mcp.store.SqlExecutionTemplate; +import org.apache.seata.mcp.store.SqlSafetyValidator; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; +import java.util.Arrays; +import java.util.Collections; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -36,8 +41,8 @@ void shouldRemovePoolWhenUnregisteringDynamicDataSource() { SqlExecutionTemplate sqlExecutionTemplate = mock(SqlExecutionTemplate.class); BusinessDataSourcesProperties properties = mock(BusinessDataSourcesProperties.class); MysqlMetadataService metadataService = mock(MysqlMetadataService.class); - BusinessDataSourceServiceImpl service = - new BusinessDataSourceServiceImpl(sqlExecutionTemplate, properties, metadataService); + BusinessDataSourceServiceImpl service = new BusinessDataSourceServiceImpl( + sqlExecutionTemplate, properties, metadataService, new SqlSafetyValidator()); when(properties.unregisterMysqlDataSource("biz")).thenReturn("business-ds://biz"); try (MockedStatic dataSourceFactory = mockStatic(DataSourceFactory.class)) { @@ -48,4 +53,24 @@ void shouldRemovePoolWhenUnregisteringDynamicDataSource() { } verify(properties).unregisterMysqlDataSource("biz"); } + + @Test + void shouldQueryTableWithinUrlDatabase() { + SqlExecutionTemplate sqlExecutionTemplate = mock(SqlExecutionTemplate.class); + BusinessDataSourcesProperties properties = mock(BusinessDataSourcesProperties.class); + MysqlMetadataService metadataService = mock(MysqlMetadataService.class); + BusinessQueryResult result = new BusinessQueryResult(); + BusinessDataSourceServiceImpl service = new BusinessDataSourceServiceImpl( + sqlExecutionTemplate, properties, metadataService, new SqlSafetyValidator()); + when(properties.getDatabaseName("business-ds://biz")).thenReturn("app"); + when(sqlExecutionTemplate.queryWithMaxRows("business-ds://biz", "SELECT `id`, `name` FROM `app`.`users`", 10)) + .thenReturn(result); + + BusinessQueryResult actual = service.queryMysqlTable( + "business-ds://biz", "users", Arrays.asList("id", "name"), Collections.emptyMap(), 10); + + assertEquals(result, actual); + verify(sqlExecutionTemplate) + .queryWithMaxRows("business-ds://biz", "SELECT `id`, `name` FROM `app`.`users`", 10); + } } diff --git a/console/src/test/java/org/apache/seata/mcp/store/SqlExecutionTemplateTest.java b/console/src/test/java/org/apache/seata/mcp/store/SqlExecutionTemplateTest.java index 2dbc8df2aba..1f2b8455222 100644 --- a/console/src/test/java/org/apache/seata/mcp/store/SqlExecutionTemplateTest.java +++ b/console/src/test/java/org/apache/seata/mcp/store/SqlExecutionTemplateTest.java @@ -143,4 +143,22 @@ void shouldRejectUndoLogQueryCaseInsensitive() { assertEquals("Querying undo_log is not allowed", exception.getMessage()); } + + @Test + void shouldRejectCrossDatabaseSelect() { + SqlSafetyValidator validator = new SqlSafetyValidator(); + + StoreException exception = assertThrows( + StoreException.class, () -> validator.validateMysqlSelect("select * from other_db.users", "app")); + + assertEquals("Cross-database query is not allowed", exception.getMessage()); + } + + @Test + void shouldAllowDefaultOrSameDatabaseSelect() { + SqlSafetyValidator validator = new SqlSafetyValidator(); + + validator.validateMysqlSelect("select * from users", "app"); + validator.validateMysqlSelect("select * from app.users", "app"); + } } diff --git a/console/src/test/java/org/apache/seata/mcp/tools/BusinessDataSourceToolsTest.java b/console/src/test/java/org/apache/seata/mcp/tools/BusinessDataSourceToolsTest.java index bd1dde7f226..1439c71cafa 100644 --- a/console/src/test/java/org/apache/seata/mcp/tools/BusinessDataSourceToolsTest.java +++ b/console/src/test/java/org/apache/seata/mcp/tools/BusinessDataSourceToolsTest.java @@ -85,7 +85,6 @@ void shouldExposeToolNamesWithoutMysqlPrefix() { "unregisterDataSource", "testDataSource", "getDataSources", - "listSchemas", "getTableNames", "getTableSchema", "queryTable", From b14eab4f8e3db87273297b0570cd6dbc2f96a100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Thu, 4 Jun 2026 01:34:27 +0800 Subject: [PATCH 29/43] refactor: remove plaintext password from datasource registration --- .../mcp/core/props/BusinessDataSourcesProperties.java | 10 ---------- .../mcp/entity/dto/MysqlDataSourceRegisterRequest.java | 9 --------- .../core/props/BusinessDataSourcesPropertiesTest.java | 6 +++--- 3 files changed, 3 insertions(+), 22 deletions(-) diff --git a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java index bf7f08110b7..b356a488924 100644 --- a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java +++ b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java @@ -59,8 +59,6 @@ public class BusinessDataSourcesProperties implements InitializingBean { private final boolean dynamicRegistrationEnabled; - private final boolean allowPlainPassword; - private final Set allowedHosts; private static final Map datasources = new ConcurrentHashMap<>(); @@ -94,8 +92,6 @@ public BusinessDataSourcesProperties(Environment env, ObjectMapper objectMapper, "seata.businessDataSources.max-dynamic-size", Integer.class, DEFAULT_MAX_DYNAMIC_DATA_SOURCES); this.dynamicRegistrationEnabled = env.getProperty("seata.businessDataSources.dynamic-registration.enabled", Boolean.class, false); - this.allowPlainPassword = env.getProperty( - "seata.businessDataSources.dynamic-registration.allow-plain-password", Boolean.class, false); this.allowedHosts = parseAllowedHosts(env.getProperty("seata.businessDataSources.dynamic-registration.allowed-hosts", "")); } @@ -226,12 +222,6 @@ private MysqlDataSourceInfo toInfo(String name, DataSourceProperties props) { } private String resolvePassword(MysqlDataSourceRegisterRequest request) { - if (StringUtils.hasText(request.getPassword())) { - if (!allowPlainPassword) { - throw new IllegalArgumentException("Plain password is not allowed, use passwordSecretRef"); - } - return request.getPassword(); - } return secretResolver.resolve(request.getPasswordSecretRef()); } diff --git a/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java b/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java index e11b3235c29..3bbe6ddce33 100644 --- a/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java +++ b/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java @@ -22,7 +22,6 @@ public class MysqlDataSourceRegisterRequest { private String url; private String username; private String passwordSecretRef; - private String password; private String datasource = "druid"; private int minConn = 10; private int maxConn = 100; @@ -60,14 +59,6 @@ public void setPasswordSecretRef(String passwordSecretRef) { this.passwordSecretRef = passwordSecretRef; } - public String getPassword() { - return password; - } - - public void setPassword(String password) { - this.password = password; - } - public String getDatasource() { return datasource; } diff --git a/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java b/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java index a33770f553b..d0d92064b10 100644 --- a/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java +++ b/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java @@ -71,15 +71,15 @@ void shouldResolvePasswordSecretRefAndRegisterMysqlResourceId() { } @Test - void shouldRejectPlainPasswordByDefault() { + void shouldRequirePasswordSecretRefForDynamicRegistration() { BusinessDataSourcesProperties properties = newProperties(enabledEnv()); MysqlDataSourceRegisterRequest request = request("biz", "localhost"); - request.setPassword("pwd"); + request.setPasswordSecretRef(""); IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> properties.registerMysqlDataSource(request)); - assertEquals("Plain password is not allowed, use passwordSecretRef", exception.getMessage()); + assertEquals("passwordSecretRef cannot be empty", exception.getMessage()); } @Test From 2e5cb05ecf670de209dd65c0232e8769b3528b84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Thu, 4 Jun 2026 01:37:38 +0800 Subject: [PATCH 30/43] docs: describe datasource registration mcp params --- .../dto/MysqlDataSourceRegisterRequest.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java b/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java index 3bbe6ddce33..ba02a66ff91 100644 --- a/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java +++ b/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java @@ -16,15 +16,42 @@ */ package org.apache.seata.mcp.entity.dto; +import org.springaicommunity.mcp.annotation.McpToolParam; + public class MysqlDataSourceRegisterRequest { + @McpToolParam( + description = "Unique data source name, used to build resourceId business-ds://{name}", + required = true) private String name; + + @McpToolParam( + description = + "MySQL JDBC URL that must include a database name, for example jdbc:mysql://host:3306/student", + required = true) private String url; + + @McpToolParam(description = "MySQL username. Use a read-only database account for query tools", required = true) private String username; + + @McpToolParam( + description = + "Server-side secret reference used to resolve the MySQL password, for example STUDENT_DB_PASSWORD", + required = true) private String passwordSecretRef; + + @McpToolParam(description = "Connection pool type. Supported values depend on the local provider, default is druid") private String datasource = "druid"; + + @McpToolParam(description = "Minimum connection pool size, default is 10", required = false) private int minConn = 10; + + @McpToolParam(description = "Maximum connection pool size, default is 100", required = false) private int maxConn = 100; + + @McpToolParam( + description = "Maximum time in milliseconds to wait for a connection, default is 5000", + required = false) private Long maxWait = 5000L; public String getName() { From 3b7dd4385cc3fe3b05db8425a9cbef2f703913f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Thu, 4 Jun 2026 01:44:05 +0800 Subject: [PATCH 31/43] style: spotless check --- .../seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java b/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java index ba02a66ff91..03ddf5a36d8 100644 --- a/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java +++ b/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java @@ -40,7 +40,9 @@ public class MysqlDataSourceRegisterRequest { required = true) private String passwordSecretRef; - @McpToolParam(description = "Connection pool type. Supported values depend on the local provider, default is druid") + @McpToolParam( + description = "Connection pool type. Supported values depend on the local provider, default is druid", + required = false) private String datasource = "druid"; @McpToolParam(description = "Minimum connection pool size, default is 10", required = false) From 5034459caaaa10a21c5a04b0cbe9b849cf471bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Thu, 4 Jun 2026 15:13:05 +0800 Subject: [PATCH 32/43] refactor: move datasource registration to console api --- .../BusinessDataSourceController.java | 71 ++++++++++++ .../props/BusinessDataSourcesProperties.java | 3 + .../dto/MysqlDataSourceRegisterRequest.java | 38 ++---- .../mcp/tools/BusinessDataSourceTools.java | 57 --------- .../BusinessDataSourceControllerTest.java | 109 ++++++++++++++++++ .../BusinessDataSourcesPropertiesTest.java | 21 ++++ .../tools/BusinessDataSourceToolsTest.java | 46 +------- 7 files changed, 214 insertions(+), 131 deletions(-) create mode 100644 console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java create mode 100644 console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java diff --git a/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java b/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java new file mode 100644 index 00000000000..860c8bb4bc5 --- /dev/null +++ b/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.console.controller; + +import org.apache.seata.common.result.SingleResult; +import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceTestResult; +import org.apache.seata.mcp.service.BusinessDataSourceService; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/businessDataSources") +public class BusinessDataSourceController { + + private final BusinessDataSourceService dataSourceService; + + public BusinessDataSourceController(BusinessDataSourceService dataSourceService) { + this.dataSourceService = dataSourceService; + } + + @GetMapping + public SingleResult> listDataSources() { + return SingleResult.success(dataSourceService.getMysqlDataSources()); + } + + @PostMapping + public SingleResult registerDataSource(@RequestBody MysqlDataSourceRegisterRequest request) { + try { + return SingleResult.success(dataSourceService.registerMysqlDataSource(request)); + } catch (Exception e) { + return SingleResult.failure(e.getMessage()); + } + } + + @PostMapping("/test") + public SingleResult testDataSource(@RequestBody MysqlDataSourceRegisterRequest request) { + return SingleResult.success(dataSourceService.testMysqlDataSource(request)); + } + + @DeleteMapping("/{name}") + public SingleResult unregisterDataSource(@PathVariable String name) { + try { + return SingleResult.success(dataSourceService.unregisterMysqlDataSource(name)); + } catch (Exception e) { + return SingleResult.failure(e.getMessage()); + } + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java index b356a488924..7c3514eefe8 100644 --- a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java +++ b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java @@ -222,6 +222,9 @@ private MysqlDataSourceInfo toInfo(String name, DataSourceProperties props) { } private String resolvePassword(MysqlDataSourceRegisterRequest request) { + if (StringUtils.hasText(request.getPassword())) { + return request.getPassword(); + } return secretResolver.resolve(request.getPasswordSecretRef()); } diff --git a/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java b/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java index 03ddf5a36d8..3e796e38358 100644 --- a/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java +++ b/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java @@ -16,44 +16,16 @@ */ package org.apache.seata.mcp.entity.dto; -import org.springaicommunity.mcp.annotation.McpToolParam; - public class MysqlDataSourceRegisterRequest { - @McpToolParam( - description = "Unique data source name, used to build resourceId business-ds://{name}", - required = true) private String name; - - @McpToolParam( - description = - "MySQL JDBC URL that must include a database name, for example jdbc:mysql://host:3306/student", - required = true) private String url; - - @McpToolParam(description = "MySQL username. Use a read-only database account for query tools", required = true) private String username; - - @McpToolParam( - description = - "Server-side secret reference used to resolve the MySQL password, for example STUDENT_DB_PASSWORD", - required = true) + private String password; private String passwordSecretRef; - - @McpToolParam( - description = "Connection pool type. Supported values depend on the local provider, default is druid", - required = false) private String datasource = "druid"; - - @McpToolParam(description = "Minimum connection pool size, default is 10", required = false) private int minConn = 10; - - @McpToolParam(description = "Maximum connection pool size, default is 100", required = false) private int maxConn = 100; - - @McpToolParam( - description = "Maximum time in milliseconds to wait for a connection, default is 5000", - required = false) private Long maxWait = 5000L; public String getName() { @@ -80,6 +52,14 @@ public void setUsername(String username) { this.username = username; } + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + public String getPasswordSecretRef() { return passwordSecretRef; } diff --git a/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java b/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java index a844184dacc..f49d29474a8 100644 --- a/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java +++ b/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java @@ -19,11 +19,9 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.seata.common.exception.StoreException; -import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; import org.apache.seata.mcp.entity.vo.BusinessQueryResult; import org.apache.seata.mcp.entity.vo.MysqlColumnInfo; import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo; -import org.apache.seata.mcp.entity.vo.MysqlDataSourceTestResult; import org.apache.seata.mcp.entity.vo.MysqlTableInfo; import org.apache.seata.mcp.service.BusinessDataSourceService; import org.slf4j.Logger; @@ -31,14 +29,8 @@ import org.springaicommunity.mcp.annotation.McpResource; import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpToolParam; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.authentication.AnonymousAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; -import java.util.Collection; import java.util.List; import java.util.Map; @@ -56,36 +48,6 @@ public BusinessDataSourceTools(BusinessDataSourceService dataSourceService, Obje this.objectMapper = objectMapper; } - @McpTool(name = "registerDataSource", description = "Register a dynamic MySQL business data source. Admin only.") - public String registerDataSource( - @McpToolParam(description = "MySQL data source registration request", required = true) - MysqlDataSourceRegisterRequest request) { - requireAdmin(); - LOGGER.info("User tries to register MySQL business data source"); - return dataSourceService.registerMysqlDataSource(request); - } - - @McpTool( - name = "unregisterDataSource", - description = "Unregister a dynamic MySQL business data source. Admin only.") - public String unregisterDataSource( - @McpToolParam(description = "The data source name", required = true) String name) { - requireAdmin(); - LOGGER.info("User tries to unregister MySQL business data source: {}", name); - return dataSourceService.unregisterMysqlDataSource(name); - } - - @McpTool( - name = "testDataSource", - description = "Test a MySQL business data source registration request. Admin only.") - public MysqlDataSourceTestResult testDataSource( - @McpToolParam(description = "MySQL data source registration request", required = true) - MysqlDataSourceRegisterRequest request) { - requireAdmin(); - LOGGER.info("User tries to test MySQL business data source"); - return dataSourceService.testMysqlDataSource(request); - } - @McpTool(name = "getDataSources", description = "Get all MySQL business data sources without sensitive fields") public List getDataSources() { LOGGER.info("User tries to get MySQL business data sources"); @@ -183,25 +145,6 @@ public String mysqlTableSchemaResource(String resourceId, String tableName) { return toJson(dataSourceService.getMysqlTableSchema(resourceId, tableName)); } - private void requireAdmin() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null - || !authentication.isAuthenticated() - || authentication instanceof AnonymousAuthenticationToken) { - throw new AccessDeniedException("Admin authority is required"); - } - Collection authorities = authentication.getAuthorities(); - if (authorities == null || authorities.isEmpty()) { - return; - } - boolean admin = authorities.stream() - .map(GrantedAuthority::getAuthority) - .anyMatch(authority -> "ADMIN".equals(authority) || "ROLE_ADMIN".equals(authority)); - if (!admin) { - throw new AccessDeniedException("Admin authority is required"); - } - } - private String toJson(Object value) { try { return objectMapper.writeValueAsString(value); diff --git a/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java b/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java new file mode 100644 index 00000000000..873e8ea085a --- /dev/null +++ b/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.console.controller; + +import org.apache.seata.common.result.SingleResult; +import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceTestResult; +import org.apache.seata.mcp.service.BusinessDataSourceService; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class BusinessDataSourceControllerTest { + + @Test + void shouldListDataSourcesWithoutSensitiveFields() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + BusinessDataSourceController controller = new BusinessDataSourceController(service); + MysqlDataSourceInfo info = new MysqlDataSourceInfo(); + info.setName("shopDemo"); + info.setResourceId("business-ds://shopDemo"); + info.setDatabaseName("mcp_shop_demo"); + when(service.getMysqlDataSources()).thenReturn(Collections.singletonList(info)); + + SingleResult> result = controller.listDataSources(); + + assertTrue(result.isSuccess()); + assertEquals("shopDemo", result.getData().get(0).getName()); + } + + @Test + void shouldRegisterDataSourceThroughConsoleApi() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + BusinessDataSourceController controller = new BusinessDataSourceController(service); + MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); + request.setPassword("pwd"); + when(service.registerMysqlDataSource(request)).thenReturn("business-ds://shopDemo"); + + SingleResult result = controller.registerDataSource(request); + + assertTrue(result.isSuccess()); + assertEquals("business-ds://shopDemo", result.getData()); + verify(service).registerMysqlDataSource(request); + } + + @Test + void shouldReturnFailureWhenRegisterFails() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + BusinessDataSourceController controller = new BusinessDataSourceController(service); + MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); + when(service.registerMysqlDataSource(request)).thenThrow(new IllegalArgumentException("bad datasource")); + + SingleResult result = controller.registerDataSource(request); + + assertFalse(result.isSuccess()); + assertEquals("bad datasource", result.getMessage()); + } + + @Test + void shouldTestDataSourceThroughConsoleApi() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + BusinessDataSourceController controller = new BusinessDataSourceController(service); + MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); + MysqlDataSourceTestResult testResult = new MysqlDataSourceTestResult(); + testResult.setSuccess(true); + when(service.testMysqlDataSource(request)).thenReturn(testResult); + + SingleResult result = controller.testDataSource(request); + + assertTrue(result.isSuccess()); + assertTrue(result.getData().isSuccess()); + } + + @Test + void shouldUnregisterDataSourceThroughConsoleApi() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + BusinessDataSourceController controller = new BusinessDataSourceController(service); + when(service.unregisterMysqlDataSource("shopDemo")).thenReturn("business-ds://shopDemo"); + + SingleResult result = controller.unregisterDataSource("shopDemo"); + + assertTrue(result.isSuccess()); + assertEquals("business-ds://shopDemo", result.getData()); + verify(service).unregisterMysqlDataSource("shopDemo"); + } +} diff --git a/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java b/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java index d0d92064b10..9095e8ce2ea 100644 --- a/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java +++ b/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java @@ -51,6 +51,27 @@ void shouldRejectDynamicRegistrationWhenDisabledByDefault() { assertEquals("Dynamic business data source registration is disabled", exception.getMessage()); } + @Test + void shouldResolvePlainPasswordAndRegisterMysqlResourceId() { + BusinessDataSourcesProperties properties = newProperties(enabledEnv()); + MysqlDataSourceRegisterRequest request = request("biz", "localhost"); + request.setPassword("pwd"); + request.setPasswordSecretRef(""); + + String resourceId = properties.registerMysqlDataSource(request); + + assertEquals("business-ds://biz", resourceId); + BusinessDataSourcesProperties.DataSourceProperties props = + BusinessDataSourcesProperties.getDatasources().get(resourceId); + assertEquals("pwd", props.getPassword()); + assertEquals("app", props.getDatabaseName()); + assertEquals( + "business-ds://biz", + BusinessDataSourcesProperties.getDataSourcesNamesAndResourceIds() + .get("biz")); + assertEquals(1, properties.getMysqlDataSourceInfos().size()); + } + @Test void shouldResolvePasswordSecretRefAndRegisterMysqlResourceId() { BusinessDataSourcesProperties properties = newProperties(enabledEnv().withProperty("MYSQL_PASS", "pwd")); diff --git a/console/src/test/java/org/apache/seata/mcp/tools/BusinessDataSourceToolsTest.java b/console/src/test/java/org/apache/seata/mcp/tools/BusinessDataSourceToolsTest.java index 1439c71cafa..9b8b2d185ac 100644 --- a/console/src/test/java/org/apache/seata/mcp/tools/BusinessDataSourceToolsTest.java +++ b/console/src/test/java/org/apache/seata/mcp/tools/BusinessDataSourceToolsTest.java @@ -17,15 +17,10 @@ package org.apache.seata.mcp.tools; import com.fasterxml.jackson.databind.ObjectMapper; -import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo; import org.apache.seata.mcp.service.BusinessDataSourceService; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springaicommunity.mcp.annotation.McpTool; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.authentication.TestingAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; import java.lang.reflect.Method; import java.util.Arrays; @@ -36,42 +31,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class BusinessDataSourceToolsTest { - @AfterEach - void tearDown() { - SecurityContextHolder.clearContext(); - } - - @Test - void shouldRejectDynamicRegistrationForNonAdminAuthority() { - BusinessDataSourceService service = mock(BusinessDataSourceService.class); - BusinessDataSourceTools tools = new BusinessDataSourceTools(service, new ObjectMapper()); - TestingAuthenticationToken authentication = new TestingAuthenticationToken("user", "pwd", "ROLE_USER"); - SecurityContextHolder.getContext().setAuthentication(authentication); - - assertThrows(AccessDeniedException.class, () -> tools.registerDataSource(new MysqlDataSourceRegisterRequest())); - } - - @Test - void shouldAllowDynamicRegistrationForAdminAuthority() { - BusinessDataSourceService service = mock(BusinessDataSourceService.class); - BusinessDataSourceTools tools = new BusinessDataSourceTools(service, new ObjectMapper()); - MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); - TestingAuthenticationToken authentication = new TestingAuthenticationToken("admin", "pwd", "ROLE_ADMIN"); - SecurityContextHolder.getContext().setAuthentication(authentication); - when(service.registerMysqlDataSource(request)).thenReturn("business-ds://biz"); - - tools.registerDataSource(request); - - verify(service).registerMysqlDataSource(request); - } - @Test void shouldExposeToolNamesWithoutMysqlPrefix() { Set toolNames = Arrays.stream(BusinessDataSourceTools.class.getDeclaredMethods()) @@ -81,15 +45,7 @@ void shouldExposeToolNamesWithoutMysqlPrefix() { assertEquals( new HashSet<>(Arrays.asList( - "registerDataSource", - "unregisterDataSource", - "testDataSource", - "getDataSources", - "getTableNames", - "getTableSchema", - "queryTable", - "explainSql", - "runSql")), + "getDataSources", "getTableNames", "getTableSchema", "queryTable", "explainSql", "runSql")), toolNames); } From 7a0a8a3b40c7cddab1b318be23bff3dbc370aac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Thu, 4 Jun 2026 15:25:43 +0800 Subject: [PATCH 33/43] feat: encrypt console datasource password input --- .../BusinessDataSourceController.java | 32 +++++++++- .../security/DataSourcePasswordCipher.java | 61 +++++++++++++++++++ .../dto/MysqlDataSourceRegisterRequest.java | 14 +++++ .../BusinessDataSourceControllerTest.java | 51 ++++++++++++---- 4 files changed, 143 insertions(+), 15 deletions(-) create mode 100644 console/src/main/java/org/apache/seata/console/security/DataSourcePasswordCipher.java diff --git a/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java b/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java index 860c8bb4bc5..5f8c97a77dc 100644 --- a/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java +++ b/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java @@ -17,6 +17,8 @@ package org.apache.seata.console.controller; import org.apache.seata.common.result.SingleResult; +import org.apache.seata.common.util.StringUtils; +import org.apache.seata.console.security.DataSourcePasswordCipher; import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo; import org.apache.seata.mcp.entity.vo.MysqlDataSourceTestResult; @@ -37,8 +39,17 @@ public class BusinessDataSourceController { private final BusinessDataSourceService dataSourceService; - public BusinessDataSourceController(BusinessDataSourceService dataSourceService) { + private final DataSourcePasswordCipher passwordCipher; + + public BusinessDataSourceController( + BusinessDataSourceService dataSourceService, DataSourcePasswordCipher passwordCipher) { this.dataSourceService = dataSourceService; + this.passwordCipher = passwordCipher; + } + + @GetMapping("/encryption/publicKey") + public SingleResult getEncryptionPublicKey() { + return SingleResult.success(passwordCipher.getPublicKey()); } @GetMapping @@ -49,6 +60,7 @@ public SingleResult> listDataSources() { @PostMapping public SingleResult registerDataSource(@RequestBody MysqlDataSourceRegisterRequest request) { try { + decryptPassword(request); return SingleResult.success(dataSourceService.registerMysqlDataSource(request)); } catch (Exception e) { return SingleResult.failure(e.getMessage()); @@ -57,7 +69,15 @@ public SingleResult registerDataSource(@RequestBody MysqlDataSourceRegis @PostMapping("/test") public SingleResult testDataSource(@RequestBody MysqlDataSourceRegisterRequest request) { - return SingleResult.success(dataSourceService.testMysqlDataSource(request)); + try { + decryptPassword(request); + return SingleResult.success(dataSourceService.testMysqlDataSource(request)); + } catch (Exception e) { + MysqlDataSourceTestResult result = new MysqlDataSourceTestResult(); + result.setSuccess(false); + result.setMessage(e.getMessage()); + return SingleResult.success(result); + } } @DeleteMapping("/{name}") @@ -68,4 +88,12 @@ public SingleResult unregisterDataSource(@PathVariable String name) { return SingleResult.failure(e.getMessage()); } } + + private void decryptPassword(MysqlDataSourceRegisterRequest request) { + if (request == null || StringUtils.isBlank(request.getEncryptedPassword())) { + return; + } + request.setPassword(passwordCipher.decrypt(request.getEncryptedPassword())); + request.setEncryptedPassword(""); + } } diff --git a/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordCipher.java b/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordCipher.java new file mode 100644 index 00000000000..e133f5f2d12 --- /dev/null +++ b/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordCipher.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.console.security; + +import org.apache.seata.common.util.ConfigTools; +import org.apache.seata.common.util.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.KeyPair; + +@Component +public class DataSourcePasswordCipher { + + private final String publicKey; + + private final String privateKey; + + public DataSourcePasswordCipher( + @Value("${seata.businessDataSources.encryption.public-key:}") String configuredPublicKey, + @Value("${seata.businessDataSources.encryption.private-key:}") String configuredPrivateKey) + throws Exception { + if (StringUtils.isNotBlank(configuredPublicKey) && StringUtils.isNotBlank(configuredPrivateKey)) { + this.publicKey = configuredPublicKey; + this.privateKey = configuredPrivateKey; + return; + } + KeyPair keyPair = ConfigTools.getKeyPair(); + this.publicKey = ConfigTools.getPublicKey(keyPair); + this.privateKey = ConfigTools.getPrivateKey(keyPair); + } + + public String getPublicKey() { + return publicKey; + } + + public String decrypt(String encryptedPassword) { + if (StringUtils.isBlank(encryptedPassword)) { + return ""; + } + try { + return ConfigTools.privateDecrypt(encryptedPassword, privateKey); + } catch (Exception e) { + throw new IllegalArgumentException("Unable to decrypt datasource password"); + } + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java b/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java index 3e796e38358..c5a0aa2dde4 100644 --- a/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java +++ b/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java @@ -16,12 +16,18 @@ */ package org.apache.seata.mcp.entity.dto; +import com.fasterxml.jackson.annotation.JsonIgnore; + public class MysqlDataSourceRegisterRequest { private String name; private String url; private String username; + private String encryptedPassword; + + @JsonIgnore private String password; + private String passwordSecretRef; private String datasource = "druid"; private int minConn = 10; @@ -52,6 +58,14 @@ public void setUsername(String username) { this.username = username; } + public String getEncryptedPassword() { + return encryptedPassword; + } + + public void setEncryptedPassword(String encryptedPassword) { + this.encryptedPassword = encryptedPassword; + } + public String getPassword() { return password; } diff --git a/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java b/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java index 873e8ea085a..691f6175610 100644 --- a/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java +++ b/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java @@ -17,11 +17,14 @@ package org.apache.seata.console.controller; import org.apache.seata.common.result.SingleResult; +import org.apache.seata.common.util.ConfigTools; +import org.apache.seata.console.security.DataSourcePasswordCipher; import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo; import org.apache.seata.mcp.entity.vo.MysqlDataSourceTestResult; import org.apache.seata.mcp.service.BusinessDataSourceService; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import java.util.Collections; import java.util.List; @@ -36,9 +39,22 @@ class BusinessDataSourceControllerTest { @Test - void shouldListDataSourcesWithoutSensitiveFields() { + void shouldReturnEncryptionPublicKey() throws Exception { BusinessDataSourceService service = mock(BusinessDataSourceService.class); - BusinessDataSourceController controller = new BusinessDataSourceController(service); + DataSourcePasswordCipher cipher = new DataSourcePasswordCipher("", ""); + BusinessDataSourceController controller = new BusinessDataSourceController(service, cipher); + + SingleResult result = controller.getEncryptionPublicKey(); + + assertTrue(result.isSuccess()); + assertFalse(result.getData().isEmpty()); + } + + @Test + void shouldListDataSourcesWithoutSensitiveFields() throws Exception { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + BusinessDataSourceController controller = + new BusinessDataSourceController(service, new DataSourcePasswordCipher("", "")); MysqlDataSourceInfo info = new MysqlDataSourceInfo(); info.setName("shopDemo"); info.setResourceId("business-ds://shopDemo"); @@ -52,24 +68,31 @@ void shouldListDataSourcesWithoutSensitiveFields() { } @Test - void shouldRegisterDataSourceThroughConsoleApi() { + void shouldRegisterDataSourceThroughConsoleApiWithEncryptedPassword() throws Exception { BusinessDataSourceService service = mock(BusinessDataSourceService.class); - BusinessDataSourceController controller = new BusinessDataSourceController(service); + DataSourcePasswordCipher cipher = new DataSourcePasswordCipher("", ""); + BusinessDataSourceController controller = new BusinessDataSourceController(service, cipher); MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); - request.setPassword("pwd"); - when(service.registerMysqlDataSource(request)).thenReturn("business-ds://shopDemo"); + request.setEncryptedPassword(ConfigTools.publicEncrypt("pwd", cipher.getPublicKey())); + when(service.registerMysqlDataSource(org.mockito.ArgumentMatchers.any())) + .thenReturn("business-ds://shopDemo"); SingleResult result = controller.registerDataSource(request); assertTrue(result.isSuccess()); assertEquals("business-ds://shopDemo", result.getData()); - verify(service).registerMysqlDataSource(request); + ArgumentCaptor captor = + ArgumentCaptor.forClass(MysqlDataSourceRegisterRequest.class); + verify(service).registerMysqlDataSource(captor.capture()); + assertEquals("pwd", captor.getValue().getPassword()); + assertEquals("", captor.getValue().getEncryptedPassword()); } @Test - void shouldReturnFailureWhenRegisterFails() { + void shouldReturnFailureWhenRegisterFails() throws Exception { BusinessDataSourceService service = mock(BusinessDataSourceService.class); - BusinessDataSourceController controller = new BusinessDataSourceController(service); + BusinessDataSourceController controller = + new BusinessDataSourceController(service, new DataSourcePasswordCipher("", "")); MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); when(service.registerMysqlDataSource(request)).thenThrow(new IllegalArgumentException("bad datasource")); @@ -80,9 +103,10 @@ void shouldReturnFailureWhenRegisterFails() { } @Test - void shouldTestDataSourceThroughConsoleApi() { + void shouldTestDataSourceThroughConsoleApi() throws Exception { BusinessDataSourceService service = mock(BusinessDataSourceService.class); - BusinessDataSourceController controller = new BusinessDataSourceController(service); + BusinessDataSourceController controller = + new BusinessDataSourceController(service, new DataSourcePasswordCipher("", "")); MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); MysqlDataSourceTestResult testResult = new MysqlDataSourceTestResult(); testResult.setSuccess(true); @@ -95,9 +119,10 @@ void shouldTestDataSourceThroughConsoleApi() { } @Test - void shouldUnregisterDataSourceThroughConsoleApi() { + void shouldUnregisterDataSourceThroughConsoleApi() throws Exception { BusinessDataSourceService service = mock(BusinessDataSourceService.class); - BusinessDataSourceController controller = new BusinessDataSourceController(service); + BusinessDataSourceController controller = + new BusinessDataSourceController(service, new DataSourcePasswordCipher("", "")); when(service.unregisterMysqlDataSource("shopDemo")).thenReturn("business-ds://shopDemo"); SingleResult result = controller.unregisterDataSource("shopDemo"); From ddb97999aca20cb581ebcdc16ded04868dce4dee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Thu, 4 Jun 2026 15:33:15 +0800 Subject: [PATCH 34/43] refactor: use console secret key for datasource password cipher --- .../BusinessDataSourceController.java | 5 -- .../security/DataSourcePasswordCipher.java | 78 ++++++++++++++----- .../BusinessDataSourceControllerTest.java | 35 +++------ 3 files changed, 69 insertions(+), 49 deletions(-) diff --git a/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java b/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java index 5f8c97a77dc..ecdf99938c6 100644 --- a/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java +++ b/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java @@ -47,11 +47,6 @@ public BusinessDataSourceController( this.passwordCipher = passwordCipher; } - @GetMapping("/encryption/publicKey") - public SingleResult getEncryptionPublicKey() { - return SingleResult.success(passwordCipher.getPublicKey()); - } - @GetMapping public SingleResult> listDataSources() { return SingleResult.success(dataSourceService.getMysqlDataSources()); diff --git a/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordCipher.java b/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordCipher.java index e133f5f2d12..a8b15cc809c 100644 --- a/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordCipher.java +++ b/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordCipher.java @@ -16,36 +16,39 @@ */ package org.apache.seata.console.security; -import org.apache.seata.common.util.ConfigTools; import org.apache.seata.common.util.StringUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import java.security.KeyPair; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; @Component public class DataSourcePasswordCipher { - private final String publicKey; + private static final String AES_ALGORITHM = "AES"; - private final String privateKey; + private static final String AES_GCM_TRANSFORMATION = "AES/GCM/NoPadding"; - public DataSourcePasswordCipher( - @Value("${seata.businessDataSources.encryption.public-key:}") String configuredPublicKey, - @Value("${seata.businessDataSources.encryption.private-key:}") String configuredPrivateKey) - throws Exception { - if (StringUtils.isNotBlank(configuredPublicKey) && StringUtils.isNotBlank(configuredPrivateKey)) { - this.publicKey = configuredPublicKey; - this.privateKey = configuredPrivateKey; - return; - } - KeyPair keyPair = ConfigTools.getKeyPair(); - this.publicKey = ConfigTools.getPublicKey(keyPair); - this.privateKey = ConfigTools.getPrivateKey(keyPair); - } + private static final int GCM_IV_LENGTH = 12; + + private static final int GCM_TAG_LENGTH_BITS = 128; + + private final SecretKeySpec secretKeySpec; + + private final SecureRandom secureRandom = new SecureRandom(); - public String getPublicKey() { - return publicKey; + public DataSourcePasswordCipher(@Value("${seata.security.secretKey}") String secretKey) { + if (StringUtils.isBlank(secretKey)) { + throw new IllegalArgumentException("seata.security.secretKey cannot be empty"); + } + this.secretKeySpec = new SecretKeySpec(sha256(secretKey), AES_ALGORITHM); } public String decrypt(String encryptedPassword) { @@ -53,9 +56,44 @@ public String decrypt(String encryptedPassword) { return ""; } try { - return ConfigTools.privateDecrypt(encryptedPassword, privateKey); + byte[] payload = Base64.getDecoder().decode(encryptedPassword); + if (payload.length <= GCM_IV_LENGTH) { + throw new IllegalArgumentException("Invalid datasource password ciphertext"); + } + byte[] iv = Arrays.copyOfRange(payload, 0, GCM_IV_LENGTH); + byte[] ciphertext = Arrays.copyOfRange(payload, GCM_IV_LENGTH, payload.length); + Cipher cipher = Cipher.getInstance(AES_GCM_TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)); + return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); } catch (Exception e) { throw new IllegalArgumentException("Unable to decrypt datasource password"); } } + + public String encrypt(String password) { + if (StringUtils.isBlank(password)) { + return ""; + } + try { + byte[] iv = new byte[GCM_IV_LENGTH]; + secureRandom.nextBytes(iv); + Cipher cipher = Cipher.getInstance(AES_GCM_TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)); + byte[] ciphertext = cipher.doFinal(password.getBytes(StandardCharsets.UTF_8)); + byte[] payload = new byte[iv.length + ciphertext.length]; + System.arraycopy(iv, 0, payload, 0, iv.length); + System.arraycopy(ciphertext, 0, payload, iv.length, ciphertext.length); + return Base64.getEncoder().encodeToString(payload); + } catch (Exception e) { + throw new IllegalArgumentException("Unable to encrypt datasource password"); + } + } + + private byte[] sha256(String secretKey) { + try { + return MessageDigest.getInstance("SHA-256").digest(secretKey.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new IllegalArgumentException("Unable to initialize datasource password cipher"); + } + } } diff --git a/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java b/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java index 691f6175610..8f2a6a250c2 100644 --- a/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java +++ b/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java @@ -17,7 +17,6 @@ package org.apache.seata.console.controller; import org.apache.seata.common.result.SingleResult; -import org.apache.seata.common.util.ConfigTools; import org.apache.seata.console.security.DataSourcePasswordCipher; import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo; @@ -39,22 +38,10 @@ class BusinessDataSourceControllerTest { @Test - void shouldReturnEncryptionPublicKey() throws Exception { - BusinessDataSourceService service = mock(BusinessDataSourceService.class); - DataSourcePasswordCipher cipher = new DataSourcePasswordCipher("", ""); - BusinessDataSourceController controller = new BusinessDataSourceController(service, cipher); - - SingleResult result = controller.getEncryptionPublicKey(); - - assertTrue(result.isSuccess()); - assertFalse(result.getData().isEmpty()); - } - - @Test - void shouldListDataSourcesWithoutSensitiveFields() throws Exception { + void shouldListDataSourcesWithoutSensitiveFields() { BusinessDataSourceService service = mock(BusinessDataSourceService.class); BusinessDataSourceController controller = - new BusinessDataSourceController(service, new DataSourcePasswordCipher("", "")); + new BusinessDataSourceController(service, new DataSourcePasswordCipher("test-secret-key")); MysqlDataSourceInfo info = new MysqlDataSourceInfo(); info.setName("shopDemo"); info.setResourceId("business-ds://shopDemo"); @@ -68,12 +55,12 @@ void shouldListDataSourcesWithoutSensitiveFields() throws Exception { } @Test - void shouldRegisterDataSourceThroughConsoleApiWithEncryptedPassword() throws Exception { + void shouldRegisterDataSourceThroughConsoleApiWithEncryptedPassword() { BusinessDataSourceService service = mock(BusinessDataSourceService.class); - DataSourcePasswordCipher cipher = new DataSourcePasswordCipher("", ""); + DataSourcePasswordCipher cipher = new DataSourcePasswordCipher("test-secret-key"); BusinessDataSourceController controller = new BusinessDataSourceController(service, cipher); MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); - request.setEncryptedPassword(ConfigTools.publicEncrypt("pwd", cipher.getPublicKey())); + request.setEncryptedPassword(cipher.encrypt("pwd")); when(service.registerMysqlDataSource(org.mockito.ArgumentMatchers.any())) .thenReturn("business-ds://shopDemo"); @@ -89,10 +76,10 @@ void shouldRegisterDataSourceThroughConsoleApiWithEncryptedPassword() throws Exc } @Test - void shouldReturnFailureWhenRegisterFails() throws Exception { + void shouldReturnFailureWhenRegisterFails() { BusinessDataSourceService service = mock(BusinessDataSourceService.class); BusinessDataSourceController controller = - new BusinessDataSourceController(service, new DataSourcePasswordCipher("", "")); + new BusinessDataSourceController(service, new DataSourcePasswordCipher("test-secret-key")); MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); when(service.registerMysqlDataSource(request)).thenThrow(new IllegalArgumentException("bad datasource")); @@ -103,10 +90,10 @@ void shouldReturnFailureWhenRegisterFails() throws Exception { } @Test - void shouldTestDataSourceThroughConsoleApi() throws Exception { + void shouldTestDataSourceThroughConsoleApi() { BusinessDataSourceService service = mock(BusinessDataSourceService.class); BusinessDataSourceController controller = - new BusinessDataSourceController(service, new DataSourcePasswordCipher("", "")); + new BusinessDataSourceController(service, new DataSourcePasswordCipher("test-secret-key")); MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); MysqlDataSourceTestResult testResult = new MysqlDataSourceTestResult(); testResult.setSuccess(true); @@ -119,10 +106,10 @@ void shouldTestDataSourceThroughConsoleApi() throws Exception { } @Test - void shouldUnregisterDataSourceThroughConsoleApi() throws Exception { + void shouldUnregisterDataSourceThroughConsoleApi() { BusinessDataSourceService service = mock(BusinessDataSourceService.class); BusinessDataSourceController controller = - new BusinessDataSourceController(service, new DataSourcePasswordCipher("", "")); + new BusinessDataSourceController(service, new DataSourcePasswordCipher("test-secret-key")); when(service.unregisterMysqlDataSource("shopDemo")).thenReturn("business-ds://shopDemo"); SingleResult result = controller.unregisterDataSource("shopDemo"); From b82db7df057d157caa6ad41d8a58f31e93cb4864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Thu, 4 Jun 2026 16:15:04 +0800 Subject: [PATCH 35/43] feat: simplify datasource password handling --- .../BusinessDataSourceController.java | 17 ++++--- .../security/DataSourcePasswordCipher.java | 11 ++++- .../dto/MysqlDataSourceRegisterRequest.java | 13 +----- .../BusinessDataSourceControllerTest.java | 45 +++++++++++++++---- 4 files changed, 60 insertions(+), 26 deletions(-) diff --git a/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java b/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java index ecdf99938c6..20796f68f5e 100644 --- a/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java +++ b/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java @@ -55,7 +55,7 @@ public SingleResult> listDataSources() { @PostMapping public SingleResult registerDataSource(@RequestBody MysqlDataSourceRegisterRequest request) { try { - decryptPassword(request); + preparePassword(request); return SingleResult.success(dataSourceService.registerMysqlDataSource(request)); } catch (Exception e) { return SingleResult.failure(e.getMessage()); @@ -65,7 +65,7 @@ public SingleResult registerDataSource(@RequestBody MysqlDataSourceRegis @PostMapping("/test") public SingleResult testDataSource(@RequestBody MysqlDataSourceRegisterRequest request) { try { - decryptPassword(request); + preparePassword(request); return SingleResult.success(dataSourceService.testMysqlDataSource(request)); } catch (Exception e) { MysqlDataSourceTestResult result = new MysqlDataSourceTestResult(); @@ -84,11 +84,16 @@ public SingleResult unregisterDataSource(@PathVariable String name) { } } - private void decryptPassword(MysqlDataSourceRegisterRequest request) { - if (request == null || StringUtils.isBlank(request.getEncryptedPassword())) { + private void preparePassword(MysqlDataSourceRegisterRequest request) { + if (request == null) { return; } - request.setPassword(passwordCipher.decrypt(request.getEncryptedPassword())); - request.setEncryptedPassword(""); + if (!passwordCipher.isEnabled()) { + return; + } + if (StringUtils.isBlank(request.getPassword())) { + return; + } + request.setPassword(passwordCipher.decrypt(request.getPassword())); } } diff --git a/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordCipher.java b/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordCipher.java index a8b15cc809c..af8c459a88c 100644 --- a/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordCipher.java +++ b/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordCipher.java @@ -42,13 +42,22 @@ public class DataSourcePasswordCipher { private final SecretKeySpec secretKeySpec; + private final boolean enabled; + private final SecureRandom secureRandom = new SecureRandom(); - public DataSourcePasswordCipher(@Value("${seata.security.secretKey}") String secretKey) { + public DataSourcePasswordCipher( + @Value("${seata.security.secretKey}") String secretKey, + @Value("${seata.businessDataSources.encryption.enabled:true}") boolean enabled) { if (StringUtils.isBlank(secretKey)) { throw new IllegalArgumentException("seata.security.secretKey cannot be empty"); } this.secretKeySpec = new SecretKeySpec(sha256(secretKey), AES_ALGORITHM); + this.enabled = enabled; + } + + public boolean isEnabled() { + return enabled; } public String decrypt(String encryptedPassword) { diff --git a/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java b/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java index c5a0aa2dde4..901daf7116a 100644 --- a/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java +++ b/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java @@ -16,16 +16,15 @@ */ package org.apache.seata.mcp.entity.dto; -import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; public class MysqlDataSourceRegisterRequest { private String name; private String url; private String username; - private String encryptedPassword; - @JsonIgnore + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) private String password; private String passwordSecretRef; @@ -58,14 +57,6 @@ public void setUsername(String username) { this.username = username; } - public String getEncryptedPassword() { - return encryptedPassword; - } - - public void setEncryptedPassword(String encryptedPassword) { - this.encryptedPassword = encryptedPassword; - } - public String getPassword() { return password; } diff --git a/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java b/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java index 8f2a6a250c2..9df20742965 100644 --- a/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java +++ b/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java @@ -41,7 +41,7 @@ class BusinessDataSourceControllerTest { void shouldListDataSourcesWithoutSensitiveFields() { BusinessDataSourceService service = mock(BusinessDataSourceService.class); BusinessDataSourceController controller = - new BusinessDataSourceController(service, new DataSourcePasswordCipher("test-secret-key")); + new BusinessDataSourceController(service, new DataSourcePasswordCipher("test-secret-key", true)); MysqlDataSourceInfo info = new MysqlDataSourceInfo(); info.setName("shopDemo"); info.setResourceId("business-ds://shopDemo"); @@ -55,12 +55,12 @@ void shouldListDataSourcesWithoutSensitiveFields() { } @Test - void shouldRegisterDataSourceThroughConsoleApiWithEncryptedPassword() { + void shouldRegisterDataSourceThroughConsoleApiWithEncryptedPasswordValue() { BusinessDataSourceService service = mock(BusinessDataSourceService.class); - DataSourcePasswordCipher cipher = new DataSourcePasswordCipher("test-secret-key"); + DataSourcePasswordCipher cipher = new DataSourcePasswordCipher("test-secret-key", true); BusinessDataSourceController controller = new BusinessDataSourceController(service, cipher); MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); - request.setEncryptedPassword(cipher.encrypt("pwd")); + request.setPassword(cipher.encrypt("pwd")); when(service.registerMysqlDataSource(org.mockito.ArgumentMatchers.any())) .thenReturn("business-ds://shopDemo"); @@ -72,14 +72,43 @@ void shouldRegisterDataSourceThroughConsoleApiWithEncryptedPassword() { ArgumentCaptor.forClass(MysqlDataSourceRegisterRequest.class); verify(service).registerMysqlDataSource(captor.capture()); assertEquals("pwd", captor.getValue().getPassword()); - assertEquals("", captor.getValue().getEncryptedPassword()); + } + + @Test + void shouldReturnFailureWhenPasswordCannotBeDecrypted() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + BusinessDataSourceController controller = + new BusinessDataSourceController(service, new DataSourcePasswordCipher("test-secret-key", true)); + MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); + request.setPassword("pwd"); + + SingleResult result = controller.registerDataSource(request); + + assertFalse(result.isSuccess()); + assertEquals("Unable to decrypt datasource password", result.getMessage()); + } + + @Test + void shouldAllowPlainPasswordWhenEncryptionDisabled() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + BusinessDataSourceController controller = + new BusinessDataSourceController(service, new DataSourcePasswordCipher("test-secret-key", false)); + MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); + request.setPassword("pwd"); + when(service.registerMysqlDataSource(request)).thenReturn("business-ds://shopDemo"); + + SingleResult result = controller.registerDataSource(request); + + assertTrue(result.isSuccess()); + assertEquals("business-ds://shopDemo", result.getData()); + verify(service).registerMysqlDataSource(request); } @Test void shouldReturnFailureWhenRegisterFails() { BusinessDataSourceService service = mock(BusinessDataSourceService.class); BusinessDataSourceController controller = - new BusinessDataSourceController(service, new DataSourcePasswordCipher("test-secret-key")); + new BusinessDataSourceController(service, new DataSourcePasswordCipher("test-secret-key", true)); MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); when(service.registerMysqlDataSource(request)).thenThrow(new IllegalArgumentException("bad datasource")); @@ -93,7 +122,7 @@ void shouldReturnFailureWhenRegisterFails() { void shouldTestDataSourceThroughConsoleApi() { BusinessDataSourceService service = mock(BusinessDataSourceService.class); BusinessDataSourceController controller = - new BusinessDataSourceController(service, new DataSourcePasswordCipher("test-secret-key")); + new BusinessDataSourceController(service, new DataSourcePasswordCipher("test-secret-key", true)); MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); MysqlDataSourceTestResult testResult = new MysqlDataSourceTestResult(); testResult.setSuccess(true); @@ -109,7 +138,7 @@ void shouldTestDataSourceThroughConsoleApi() { void shouldUnregisterDataSourceThroughConsoleApi() { BusinessDataSourceService service = mock(BusinessDataSourceService.class); BusinessDataSourceController controller = - new BusinessDataSourceController(service, new DataSourcePasswordCipher("test-secret-key")); + new BusinessDataSourceController(service, new DataSourcePasswordCipher("test-secret-key", true)); when(service.unregisterMysqlDataSource("shopDemo")).thenReturn("business-ds://shopDemo"); SingleResult result = controller.unregisterDataSource("shopDemo"); From c2bd8876bd5c71480d5daf6dc8df2d048f9bed9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Thu, 4 Jun 2026 17:40:57 +0800 Subject: [PATCH 36/43] feat: add business datasource console page --- .../BusinessDataSourceController.java | 21 +- .../DataSourcePasswordTransportCipher.java | 99 ++++ .../resources/static/console-fe/src/app.tsx | 6 +- .../static/console-fe/src/locales/en-us.ts | 42 ++ .../static/console-fe/src/locales/index.d.ts | 1 + .../static/console-fe/src/locales/zh-cn.ts | 42 ++ .../BusinessDataSource/BusinessDataSource.tsx | 474 ++++++++++++++++++ .../src/pages/BusinessDataSource/index.scss | 31 ++ .../src/pages/BusinessDataSource/index.ts | 19 + .../static/console-fe/src/router.tsx | 2 + .../src/service/businessDataSource.ts | 139 +++++ .../static/console-fe/src/utils/request.ts | 2 +- .../BusinessDataSourceControllerTest.java | 61 ++- .../src/main/resources/application.yml | 6 +- 14 files changed, 926 insertions(+), 19 deletions(-) create mode 100644 console/src/main/java/org/apache/seata/console/security/DataSourcePasswordTransportCipher.java create mode 100644 console/src/main/resources/static/console-fe/src/pages/BusinessDataSource/BusinessDataSource.tsx create mode 100644 console/src/main/resources/static/console-fe/src/pages/BusinessDataSource/index.scss create mode 100644 console/src/main/resources/static/console-fe/src/pages/BusinessDataSource/index.ts create mode 100644 console/src/main/resources/static/console-fe/src/service/businessDataSource.ts diff --git a/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java b/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java index 20796f68f5e..b3dd5048bf5 100644 --- a/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java +++ b/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java @@ -19,6 +19,7 @@ import org.apache.seata.common.result.SingleResult; import org.apache.seata.common.util.StringUtils; import org.apache.seata.console.security.DataSourcePasswordCipher; +import org.apache.seata.console.security.DataSourcePasswordTransportCipher; import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo; import org.apache.seata.mcp.entity.vo.MysqlDataSourceTestResult; @@ -41,10 +42,15 @@ public class BusinessDataSourceController { private final DataSourcePasswordCipher passwordCipher; + private final DataSourcePasswordTransportCipher passwordTransportCipher; + public BusinessDataSourceController( - BusinessDataSourceService dataSourceService, DataSourcePasswordCipher passwordCipher) { + BusinessDataSourceService dataSourceService, + DataSourcePasswordCipher passwordCipher, + DataSourcePasswordTransportCipher passwordTransportCipher) { this.dataSourceService = dataSourceService; this.passwordCipher = passwordCipher; + this.passwordTransportCipher = passwordTransportCipher; } @GetMapping @@ -52,6 +58,11 @@ public SingleResult> listDataSources() { return SingleResult.success(dataSourceService.getMysqlDataSources()); } + @GetMapping("/password/publicKey") + public SingleResult getPasswordPublicKey() { + return SingleResult.success(passwordTransportCipher.getPublicKey()); + } + @PostMapping public SingleResult registerDataSource(@RequestBody MysqlDataSourceRegisterRequest request) { try { @@ -88,10 +99,14 @@ private void preparePassword(MysqlDataSourceRegisterRequest request) { if (request == null) { return; } - if (!passwordCipher.isEnabled()) { + if (StringUtils.isBlank(request.getPassword())) { return; } - if (StringUtils.isBlank(request.getPassword())) { + if (passwordTransportCipher.isEncrypted(request.getPassword())) { + request.setPassword(passwordTransportCipher.decrypt(request.getPassword())); + return; + } + if (!passwordCipher.isEnabled()) { return; } request.setPassword(passwordCipher.decrypt(request.getPassword())); diff --git a/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordTransportCipher.java b/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordTransportCipher.java new file mode 100644 index 00000000000..48dfacf322c --- /dev/null +++ b/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordTransportCipher.java @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.console.security; + +import org.apache.seata.common.util.StringUtils; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.OAEPParameterSpec; +import javax.crypto.spec.PSource; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.spec.MGF1ParameterSpec; +import java.util.Base64; + +@Component +public class DataSourcePasswordTransportCipher { + + private static final String RSA_ALGORITHM = "RSA"; + + private static final String RSA_OAEP_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"; + + private static final int RSA_KEY_SIZE = 2048; + + private static final String TRANSPORT_CIPHER_PREFIX = "rsa:"; + + private final KeyPair keyPair; + + public DataSourcePasswordTransportCipher() { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance(RSA_ALGORITHM); + generator.initialize(RSA_KEY_SIZE); + this.keyPair = generator.generateKeyPair(); + } catch (Exception e) { + throw new IllegalArgumentException("Unable to initialize datasource password transport cipher"); + } + } + + public String getPublicKey() { + return Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); + } + + public boolean isEncrypted(String password) { + return StringUtils.isNotBlank(password) && password.startsWith(TRANSPORT_CIPHER_PREFIX); + } + + public String encrypt(String password) { + if (StringUtils.isBlank(password)) { + return ""; + } + try { + Cipher cipher = newCipher(Cipher.ENCRYPT_MODE); + byte[] ciphertext = cipher.doFinal(password.getBytes(StandardCharsets.UTF_8)); + return TRANSPORT_CIPHER_PREFIX + Base64.getEncoder().encodeToString(ciphertext); + } catch (Exception e) { + throw new IllegalArgumentException("Unable to encrypt datasource password for transport"); + } + } + + public String decrypt(String encryptedPassword) { + if (StringUtils.isBlank(encryptedPassword)) { + return ""; + } + if (!isEncrypted(encryptedPassword)) { + throw new IllegalArgumentException("Invalid datasource password transport ciphertext"); + } + try { + String ciphertext = encryptedPassword.substring(TRANSPORT_CIPHER_PREFIX.length()); + byte[] payload = Base64.getDecoder().decode(ciphertext); + Cipher cipher = newCipher(Cipher.DECRYPT_MODE); + return new String(cipher.doFinal(payload), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new IllegalArgumentException("Unable to decrypt datasource password for transport"); + } + } + + private Cipher newCipher(int mode) throws Exception { + Cipher cipher = Cipher.getInstance(RSA_OAEP_TRANSFORMATION); + OAEPParameterSpec spec = + new OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT); + cipher.init(mode, mode == Cipher.ENCRYPT_MODE ? keyPair.getPublic() : keyPair.getPrivate(), spec); + return cipher; + } +} diff --git a/console/src/main/resources/static/console-fe/src/app.tsx b/console/src/main/resources/static/console-fe/src/app.tsx index c64664e3bf1..53681b5a696 100644 --- a/console/src/main/resources/static/console-fe/src/app.tsx +++ b/console/src/main/resources/static/console-fe/src/app.tsx @@ -78,7 +78,7 @@ class App extends React.Component { get menu() { const { locale }: AppPropsType = this.props; const { MenuRouter = {} } = locale; - const { transactionInfo, globalLockInfo, clusterManager, sagaStatemachineDesigner } = MenuRouter; + const { transactionInfo, globalLockInfo, clusterManager, businessDataSource, sagaStatemachineDesigner } = MenuRouter; return { items: [ // { @@ -97,6 +97,10 @@ class App extends React.Component { key: '/cluster/list', label: clusterManager, }, + { + key: '/business-datasource/list', + label: businessDataSource, + }, { key: '/sagastatemachinedesigner', label: sagaStatemachineDesigner, diff --git a/console/src/main/resources/static/console-fe/src/locales/en-us.ts b/console/src/main/resources/static/console-fe/src/locales/en-us.ts index 2ea2c9f66b8..0ab4a1cdcfc 100644 --- a/console/src/main/resources/static/console-fe/src/locales/en-us.ts +++ b/console/src/main/resources/static/console-fe/src/locales/en-us.ts @@ -23,6 +23,7 @@ const enUs: ILocale = { transactionInfo: 'TransactionInfo', globalLockInfo: 'GlobalLockInfo', clusterManager: 'ClusterManager', + businessDataSource: 'DataSource', sagaStatemachineDesigner: 'SagaStatemachineDesigner', }, Header: { @@ -140,6 +141,47 @@ const enUs: ILocale = { transactionEndpoint: 'Transaction Endpoint', metadataDialogTitle: 'Metadata', }, + BusinessDataSource: { + title: 'Data Source', + subTitle: 'Business Data Source Management', + keywordLabel: 'Keyword', + keywordPlaceholder: 'Search name, resourceId, database or datasource', + resetButtonLabel: 'Reset', + searchButtonLabel: 'Query', + refreshButtonLabel: 'Refresh', + createButtonLabel: 'Create Data Source', + createDialogTitle: 'Create Business Data Source', + testButtonLabel: 'Test Connection', + confirmButtonLabel: 'Confirm', + cancelButtonLabel: 'Cancel', + nameLabel: 'Name', + urlLabel: 'JDBC URL', + usernameLabel: 'Username', + passwordLabel: 'Password', + passwordSecretRefLabel: 'Password Secret Ref', + datasourceLabel: 'Datasource', + minConnLabel: 'Min Connections', + maxConnLabel: 'Max Connections', + maxWaitLabel: 'Max Wait(ms)', + databaseNameLabel: 'Database', + enabledLabel: 'Enabled', + dynamicLabel: 'Dynamic', + operations: 'Operations', + deleteLabel: 'Delete', + staticSourceLabel: 'Static', + yesLabel: 'Yes', + noLabel: 'No', + successLabel: 'Success', + failedLabel: 'Failed', + validationRequiredMessage: 'Please enter name, JDBC URL and username', + passwordRequiredMessage: 'Please enter password or password secret ref', + createSuccessMessage: 'Data source registered successfully', + testSuccessMessage: 'Connection test succeeded', + testFailedMessage: 'Connection test failed', + deleteTitle: 'Delete data source', + deleteContent: 'Confirm to delete dynamic data source', + deleteSuccessMessage: 'Data source deleted successfully', + }, codeMessage: { 200: 'The server successfully returned the requested data.', 201: 'New or modified data successful.', diff --git a/console/src/main/resources/static/console-fe/src/locales/index.d.ts b/console/src/main/resources/static/console-fe/src/locales/index.d.ts index 806b6f91f0d..1549943d1c6 100644 --- a/console/src/main/resources/static/console-fe/src/locales/index.d.ts +++ b/console/src/main/resources/static/console-fe/src/locales/index.d.ts @@ -26,5 +26,6 @@ export interface ILocale { TransactionInfo: ILocaleMap; GlobalLockInfo: ILocaleMap; ClusterManager: ILocaleMap; + BusinessDataSource: ILocaleMap; codeMessage: ILocaleMap; } diff --git a/console/src/main/resources/static/console-fe/src/locales/zh-cn.ts b/console/src/main/resources/static/console-fe/src/locales/zh-cn.ts index c4b4f652f28..fc47653b431 100644 --- a/console/src/main/resources/static/console-fe/src/locales/zh-cn.ts +++ b/console/src/main/resources/static/console-fe/src/locales/zh-cn.ts @@ -23,6 +23,7 @@ const zhCn: ILocale = { transactionInfo: '事务信息', globalLockInfo: '全局锁信息', clusterManager: '集群管理', + businessDataSource: '数据源配置', sagaStatemachineDesigner: 'Saga状态机设计器', }, Header: { @@ -140,6 +141,47 @@ const zhCn: ILocale = { transactionEndpoint: '事务端点', metadataDialogTitle: '元数据', }, + BusinessDataSource: { + title: '数据源配置', + subTitle: '业务数据源管理', + keywordLabel: '关键字', + keywordPlaceholder: '搜索名称、resourceId、数据库或连接池', + resetButtonLabel: '重置', + searchButtonLabel: '查询', + refreshButtonLabel: '刷新', + createButtonLabel: '新增数据源', + createDialogTitle: '新增业务数据源', + testButtonLabel: '测试连接', + confirmButtonLabel: '确认', + cancelButtonLabel: '取消', + nameLabel: '名称', + urlLabel: 'JDBC地址', + usernameLabel: '用户名', + passwordLabel: '密码', + passwordSecretRefLabel: '密码密钥引用', + datasourceLabel: '连接池', + minConnLabel: '最小连接数', + maxConnLabel: '最大连接数', + maxWaitLabel: '最大等待时间(ms)', + databaseNameLabel: '数据库', + enabledLabel: '启用', + dynamicLabel: '动态注册', + operations: '操作', + deleteLabel: '删除', + staticSourceLabel: '静态配置', + yesLabel: '是', + noLabel: '否', + successLabel: '成功', + failedLabel: '失败', + validationRequiredMessage: '请输入名称、JDBC地址和用户名', + passwordRequiredMessage: '请输入密码或密码密钥引用', + createSuccessMessage: '数据源注册成功', + testSuccessMessage: '连接测试成功', + testFailedMessage: '连接测试失败', + deleteTitle: '删除数据源', + deleteContent: '确认删除动态数据源', + deleteSuccessMessage: '数据源删除成功', + }, codeMessage: { 200: '服务器成功返回请求的数据。', 201: '新建或修改数据成功。', diff --git a/console/src/main/resources/static/console-fe/src/pages/BusinessDataSource/BusinessDataSource.tsx b/console/src/main/resources/static/console-fe/src/pages/BusinessDataSource/BusinessDataSource.tsx new file mode 100644 index 00000000000..c5784d76de9 --- /dev/null +++ b/console/src/main/resources/static/console-fe/src/pages/BusinessDataSource/BusinessDataSource.tsx @@ -0,0 +1,474 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { ConfigProvider, Table, Button, Form, Icon, Dialog, Select, Input, Message } from '@alicloud/console-components'; +import Actions from '@alicloud/console-components-actions'; +import { withRouter } from 'react-router-dom'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import Page from '@/components/Page'; +import { GlobalProps } from '@/module'; +import { + BusinessDataSourceForm, + BusinessDataSourceInfo, + fetchBusinessDataSources, + registerBusinessDataSource, + testBusinessDataSource, + unregisterBusinessDataSource, +} from '@/service/businessDataSource'; + +import './index.scss'; + +const FormItem = Form.Item; + +type BusinessDataSourceLocale = { + title?: string; + subTitle?: string; + keywordLabel?: string; + keywordPlaceholder?: string; + resetButtonLabel?: string; + searchButtonLabel?: string; + refreshButtonLabel?: string; + createButtonLabel?: string; + createDialogTitle?: string; + testButtonLabel?: string; + confirmButtonLabel?: string; + cancelButtonLabel?: string; + nameLabel?: string; + urlLabel?: string; + usernameLabel?: string; + passwordLabel?: string; + passwordSecretRefLabel?: string; + datasourceLabel?: string; + minConnLabel?: string; + maxConnLabel?: string; + maxWaitLabel?: string; + databaseNameLabel?: string; + enabledLabel?: string; + dynamicLabel?: string; + operations?: string; + deleteLabel?: string; + staticSourceLabel?: string; + yesLabel?: string; + noLabel?: string; + successLabel?: string; + failedLabel?: string; + validationRequiredMessage?: string; + passwordRequiredMessage?: string; + createSuccessMessage?: string; + testSuccessMessage?: string; + testFailedMessage?: string; + deleteTitle?: string; + deleteContent?: string; + deleteSuccessMessage?: string; +}; + +type BusinessDataSourceState = { + list: BusinessDataSourceInfo[]; + keyword: string; + loading: boolean; + dialogVisible: boolean; + dialogLoading: boolean; + testLoading: boolean; + form: BusinessDataSourceForm; +}; + +const defaultForm: BusinessDataSourceForm = { + name: '', + url: '', + username: '', + password: '', + passwordSecretRef: '', + datasource: 'druid', + minConn: 10, + maxConn: 100, + maxWait: 5000, +}; + +class BusinessDataSource extends React.Component { + static displayName = 'BusinessDataSource'; + + static propTypes = { + locale: PropTypes.object, + }; + + state: BusinessDataSourceState = { + list: [], + keyword: '', + loading: false, + dialogVisible: false, + dialogLoading: false, + testLoading: false, + form: { ...defaultForm }, + }; + + componentDidMount = () => { + this.loadDataSources(); + }; + + loadDataSources = async () => { + this.setState(prevState => ({ + ...prevState, + loading: true, + })); + try { + const list = await fetchBusinessDataSources(); + this.setState(prevState => ({ + ...prevState, + list, + loading: false, + })); + } catch (error) { + this.setState(prevState => ({ + ...prevState, + loading: false, + })); + } + }; + + searchFilterOnChange = (value: string) => { + this.setState(prevState => ({ + ...prevState, + keyword: value, + })); + }; + + resetSearchFilter = () => { + this.setState(prevState => ({ + ...prevState, + keyword: '', + })); + }; + + showCreateDialog = () => { + this.setState(prevState => ({ + ...prevState, + dialogVisible: true, + form: { ...defaultForm }, + })); + }; + + closeCreateDialog = () => { + this.setState(prevState => ({ + ...prevState, + dialogVisible: false, + dialogLoading: false, + testLoading: false, + })); + }; + + formOnChange = (key: keyof BusinessDataSourceForm, value: string | number) => { + this.setState(prevState => ({ + ...prevState, + form: { + ...prevState.form, + [key]: value, + }, + })); + }; + + numberFormOnChange = (key: keyof BusinessDataSourceForm, value: string) => { + const nextValue = Number(value); + this.formOnChange(key, Number.isNaN(nextValue) ? 0 : nextValue); + }; + + validateForm = () => { + const { locale } = this.props; + const rawLocale = locale.BusinessDataSource; + const businessDataSourceLocale: BusinessDataSourceLocale = typeof rawLocale === 'object' && rawLocale !== null ? rawLocale : {}; + const { validationRequiredMessage, passwordRequiredMessage } = businessDataSourceLocale; + const { name, url, username, password, passwordSecretRef } = this.state.form; + if (!name || !url || !username) { + Message.error(validationRequiredMessage || 'Please enter name, URL and username'); + return false; + } + if (!password && !passwordSecretRef) { + Message.error(passwordRequiredMessage || 'Please enter password or password secret ref'); + return false; + } + return true; + }; + + showErrorMessage = (error: any) => { + if (error && error.message) { + Message.error(error.message); + } + }; + + createDataSource = async () => { + const { locale } = this.props; + const rawLocale = locale.BusinessDataSource; + const businessDataSourceLocale: BusinessDataSourceLocale = typeof rawLocale === 'object' && rawLocale !== null ? rawLocale : {}; + const { createSuccessMessage } = businessDataSourceLocale; + if (!this.validateForm()) { + return; + } + this.setState(prevState => ({ + ...prevState, + dialogLoading: true, + })); + try { + await registerBusinessDataSource(this.state.form); + Message.success(createSuccessMessage || 'Data source registered successfully'); + this.closeCreateDialog(); + this.loadDataSources(); + } catch (error) { + this.showErrorMessage(error); + this.setState(prevState => ({ + ...prevState, + dialogLoading: false, + })); + } + }; + + testDataSource = async () => { + const { locale } = this.props; + const rawLocale = locale.BusinessDataSource; + const businessDataSourceLocale: BusinessDataSourceLocale = typeof rawLocale === 'object' && rawLocale !== null ? rawLocale : {}; + const { testSuccessMessage, testFailedMessage } = businessDataSourceLocale; + if (!this.validateForm()) { + return; + } + this.setState(prevState => ({ + ...prevState, + testLoading: true, + })); + try { + const result = await testBusinessDataSource(this.state.form); + const detail = result && result.message ? `: ${result.message}` : ''; + if (result && result.success) { + Message.success(`${testSuccessMessage || 'Connection test succeeded'}${detail}`); + } else { + Message.error(`${testFailedMessage || 'Connection test failed'}${detail}`); + } + } catch (error) { + this.showErrorMessage(error); + } finally { + this.setState(prevState => ({ + ...prevState, + testLoading: false, + })); + } + }; + + deleteDataSource = (record: BusinessDataSourceInfo) => { + const { locale } = this.props; + const rawLocale = locale.BusinessDataSource; + const businessDataSourceLocale: BusinessDataSourceLocale = typeof rawLocale === 'object' && rawLocale !== null ? rawLocale : {}; + const { deleteTitle, deleteContent, deleteSuccessMessage } = businessDataSourceLocale; + Dialog.confirm({ + title: deleteTitle || 'Delete data source', + content: `${deleteContent || 'Confirm to delete dynamic data source'}: ${record.name}`, + onOk: async () => { + await unregisterBusinessDataSource(record.name); + Message.success(deleteSuccessMessage || 'Data source deleted successfully'); + this.loadDataSources(); + }, + }); + }; + + getFilteredList = () => { + const { keyword, list } = this.state; + const normalizedKeyword = keyword.trim().toLowerCase(); + if (!normalizedKeyword) { + return list; + } + return list.filter(item => { + return [item.name, item.resourceId, item.databaseName, item.datasource] + .filter(Boolean) + .some(value => String(value).toLowerCase().indexOf(normalizedKeyword) >= 0); + }); + }; + + render() { + const { locale } = this.props; + const rawLocale = locale.BusinessDataSource; + const businessDataSourceLocale: BusinessDataSourceLocale = typeof rawLocale === 'object' && rawLocale !== null ? rawLocale : {}; + const { + title, + subTitle, + keywordLabel, + keywordPlaceholder, + resetButtonLabel, + searchButtonLabel, + refreshButtonLabel, + createButtonLabel, + createDialogTitle, + testButtonLabel, + confirmButtonLabel, + cancelButtonLabel, + nameLabel, + urlLabel, + usernameLabel, + passwordLabel, + passwordSecretRefLabel, + datasourceLabel, + minConnLabel, + maxConnLabel, + maxWaitLabel, + databaseNameLabel, + enabledLabel, + dynamicLabel, + operations, + deleteLabel, + staticSourceLabel, + yesLabel, + noLabel, + } = businessDataSourceLocale; + const filteredList = this.getFilteredList(); + return ( + +
+ + { this.searchFilterOnChange(value); }} + /> + + + + {resetButtonLabel || 'Reset'} + + + + + {searchButtonLabel || 'Search'} + + + + + + + + +
+ + + + + + + (value ? (yesLabel || 'Yes') : (noLabel || 'No'))} /> + (value ? (yesLabel || 'Yes') : (noLabel || 'No'))} /> + { + return ( + + {record.dynamic ? ( + + ) : ( + {staticSourceLabel || 'Static'} + )} + + ); + }} + /> +
+ + +
+ + { this.formOnChange('name', value); }} /> + + + { this.formOnChange('url', value); }} + /> + + + { this.formOnChange('username', value); }} /> + + + { this.formOnChange('password', value); }} + /> + + + { this.formOnChange('passwordSecretRef', value); }} + /> + + + { this.numberFormOnChange('minConn', value); }} /> + + + { this.numberFormOnChange('maxConn', value); }} /> + + + { this.numberFormOnChange('maxWait', value); }} /> + + + + +
+
+
+ ); + } +} + +const mapStateToProps = (state: any) => ({ + locale: state.locale.locale, +}); + +export default connect(mapStateToProps)(withRouter(ConfigProvider.config(BusinessDataSource, {}))); diff --git a/console/src/main/resources/static/console-fe/src/pages/BusinessDataSource/index.scss b/console/src/main/resources/static/console-fe/src/pages/BusinessDataSource/index.scss new file mode 100644 index 00000000000..1a4c242f9df --- /dev/null +++ b/console/src/main/resources/static/console-fe/src/pages/BusinessDataSource/index.scss @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.business-datasource-toolbar { + margin-bottom: 16px; +} + +.business-datasource-dialog-form { + .next-form-item { + margin-bottom: 16px; + } + + .next-input, + .next-select { + width: 360px; + } +} diff --git a/console/src/main/resources/static/console-fe/src/pages/BusinessDataSource/index.ts b/console/src/main/resources/static/console-fe/src/pages/BusinessDataSource/index.ts new file mode 100644 index 00000000000..168dc921820 --- /dev/null +++ b/console/src/main/resources/static/console-fe/src/pages/BusinessDataSource/index.ts @@ -0,0 +1,19 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BusinessDataSource from './BusinessDataSource'; + +export default BusinessDataSource; diff --git a/console/src/main/resources/static/console-fe/src/router.tsx b/console/src/main/resources/static/console-fe/src/router.tsx index d3957a29992..a6348ab6157 100644 --- a/console/src/main/resources/static/console-fe/src/router.tsx +++ b/console/src/main/resources/static/console-fe/src/router.tsx @@ -19,6 +19,7 @@ import Overview from '@/pages/Overview'; import TransactionInfo from '@/pages/TransactionInfo'; import GlobalLockInfo from './pages/GlobalLockInfo'; import ClusterManager from './pages/ClusterManager'; +import BusinessDataSource from './pages/BusinessDataSource'; export default [ // { path: '/', exact: true, render: () => }, @@ -26,4 +27,5 @@ export default [ { path: '/transaction/list', component: TransactionInfo }, { path: '/globallock/list', component: GlobalLockInfo }, { path: '/cluster/list', component: ClusterManager }, + { path: '/business-datasource/list', component: BusinessDataSource }, ]; diff --git a/console/src/main/resources/static/console-fe/src/service/businessDataSource.ts b/console/src/main/resources/static/console-fe/src/service/businessDataSource.ts new file mode 100644 index 00000000000..b1d7f8d2b22 --- /dev/null +++ b/console/src/main/resources/static/console-fe/src/service/businessDataSource.ts @@ -0,0 +1,139 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import request from '@/utils/request'; + +export type BusinessDataSourceInfo = { + name: string; + resourceId: string; + databaseName: string; + datasource: string; + dynamic: boolean; + enabled: boolean; +}; + +export type BusinessDataSourceForm = { + name: string; + url: string; + username: string; + password: string; + passwordSecretRef: string; + datasource: string; + minConn: number; + maxConn: number; + maxWait: number; +}; + +export type BusinessDataSourceTestResult = { + success: boolean; + message: string; + validationQuery: string; + elapsedMs: number; +}; + +let passwordPublicKeyPromise: Promise | null = null; + +function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary = window.atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + const chunks: string[] = []; + for (let i = 0; i < bytes.length; i += 0x8000) { + chunks.push(String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + 0x8000)))); + } + return window.btoa(chunks.join('')); +} + +async function fetchPasswordPublicKey(): Promise { + if (!passwordPublicKeyPromise) { + passwordPublicKeyPromise = request('/businessDataSources/password/publicKey', { + method: 'get', + }).then(result => result.data); + } + return passwordPublicKeyPromise; +} + +async function encryptPassword(password: string): Promise { + if (!password || password.startsWith('rsa:')) { + return password; + } + if (!window.crypto || !window.crypto.subtle) { + throw new Error('The current browser does not support password encryption'); + } + const publicKey = await fetchPasswordPublicKey(); + const cryptoKey = await window.crypto.subtle.importKey( + 'spki', + base64ToArrayBuffer(publicKey), + { + name: 'RSA-OAEP', + hash: 'SHA-256', + }, + false, + ['encrypt'], + ); + const ciphertext = await window.crypto.subtle.encrypt( + { name: 'RSA-OAEP' }, + cryptoKey, + new TextEncoder().encode(password), + ); + return `rsa:${arrayBufferToBase64(ciphertext)}`; +} + +async function encryptBusinessDataSourcePassword(data: BusinessDataSourceForm): Promise { + return { + ...data, + password: await encryptPassword(data.password), + }; +} + +export async function fetchBusinessDataSources(): Promise { + const result = await request('/businessDataSources', { + method: 'get', + }); + return result.data || []; +} + +export async function registerBusinessDataSource(data: BusinessDataSourceForm): Promise { + const encryptedData = await encryptBusinessDataSourcePassword(data); + const result = await request('/businessDataSources', { + method: 'post', + data: encryptedData, + }); + return result.data; +} + +export async function testBusinessDataSource(data: BusinessDataSourceForm): Promise { + const encryptedData = await encryptBusinessDataSourcePassword(data); + const result = await request('/businessDataSources/test', { + method: 'post', + data: encryptedData, + }); + return result.data; +} + +export async function unregisterBusinessDataSource(name: string): Promise { + const result = await request(`/businessDataSources/${encodeURIComponent(name)}`, { + method: 'delete', + }); + return result.data; +} diff --git a/console/src/main/resources/static/console-fe/src/utils/request.ts b/console/src/main/resources/static/console-fe/src/utils/request.ts index 1634022fe76..fad26fd4b90 100644 --- a/console/src/main/resources/static/console-fe/src/utils/request.ts +++ b/console/src/main/resources/static/console-fe/src/utils/request.ts @@ -54,7 +54,7 @@ const createRequest = (baseURL: string, generalErrorMessage: string = 'Request e error => { if (error.response) { const { status } = error.response; - if (status === 403 || status === 401) { + if (status === 401) { (window as any).globalHistory.replace('/login'); return; } diff --git a/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java b/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java index 9df20742965..405d26a60e9 100644 --- a/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java +++ b/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java @@ -18,6 +18,7 @@ import org.apache.seata.common.result.SingleResult; import org.apache.seata.console.security.DataSourcePasswordCipher; +import org.apache.seata.console.security.DataSourcePasswordTransportCipher; import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo; import org.apache.seata.mcp.entity.vo.MysqlDataSourceTestResult; @@ -40,8 +41,7 @@ class BusinessDataSourceControllerTest { @Test void shouldListDataSourcesWithoutSensitiveFields() { BusinessDataSourceService service = mock(BusinessDataSourceService.class); - BusinessDataSourceController controller = - new BusinessDataSourceController(service, new DataSourcePasswordCipher("test-secret-key", true)); + BusinessDataSourceController controller = newController(service, true); MysqlDataSourceInfo info = new MysqlDataSourceInfo(); info.setName("shopDemo"); info.setResourceId("business-ds://shopDemo"); @@ -58,7 +58,8 @@ void shouldListDataSourcesWithoutSensitiveFields() { void shouldRegisterDataSourceThroughConsoleApiWithEncryptedPasswordValue() { BusinessDataSourceService service = mock(BusinessDataSourceService.class); DataSourcePasswordCipher cipher = new DataSourcePasswordCipher("test-secret-key", true); - BusinessDataSourceController controller = new BusinessDataSourceController(service, cipher); + BusinessDataSourceController controller = + new BusinessDataSourceController(service, cipher, new DataSourcePasswordTransportCipher()); MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); request.setPassword(cipher.encrypt("pwd")); when(service.registerMysqlDataSource(org.mockito.ArgumentMatchers.any())) @@ -74,11 +75,41 @@ void shouldRegisterDataSourceThroughConsoleApiWithEncryptedPasswordValue() { assertEquals("pwd", captor.getValue().getPassword()); } + @Test + void shouldExposePasswordPublicKey() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + BusinessDataSourceController controller = newController(service, true); + + SingleResult result = controller.getPasswordPublicKey(); + + assertTrue(result.isSuccess()); + assertTrue(result.getData().length() > 0); + } + + @Test + void shouldRegisterDataSourceThroughConsoleApiWithTransportEncryptedPasswordValue() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + DataSourcePasswordTransportCipher transportCipher = new DataSourcePasswordTransportCipher(); + BusinessDataSourceController controller = new BusinessDataSourceController( + service, new DataSourcePasswordCipher("test-secret-key", false), transportCipher); + MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); + request.setPassword(transportCipher.encrypt("pwd")); + when(service.registerMysqlDataSource(org.mockito.ArgumentMatchers.any())) + .thenReturn("business-ds://shopDemo"); + + SingleResult result = controller.registerDataSource(request); + + assertTrue(result.isSuccess()); + ArgumentCaptor captor = + ArgumentCaptor.forClass(MysqlDataSourceRegisterRequest.class); + verify(service).registerMysqlDataSource(captor.capture()); + assertEquals("pwd", captor.getValue().getPassword()); + } + @Test void shouldReturnFailureWhenPasswordCannotBeDecrypted() { BusinessDataSourceService service = mock(BusinessDataSourceService.class); - BusinessDataSourceController controller = - new BusinessDataSourceController(service, new DataSourcePasswordCipher("test-secret-key", true)); + BusinessDataSourceController controller = newController(service, true); MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); request.setPassword("pwd"); @@ -91,8 +122,7 @@ void shouldReturnFailureWhenPasswordCannotBeDecrypted() { @Test void shouldAllowPlainPasswordWhenEncryptionDisabled() { BusinessDataSourceService service = mock(BusinessDataSourceService.class); - BusinessDataSourceController controller = - new BusinessDataSourceController(service, new DataSourcePasswordCipher("test-secret-key", false)); + BusinessDataSourceController controller = newController(service, false); MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); request.setPassword("pwd"); when(service.registerMysqlDataSource(request)).thenReturn("business-ds://shopDemo"); @@ -107,8 +137,7 @@ void shouldAllowPlainPasswordWhenEncryptionDisabled() { @Test void shouldReturnFailureWhenRegisterFails() { BusinessDataSourceService service = mock(BusinessDataSourceService.class); - BusinessDataSourceController controller = - new BusinessDataSourceController(service, new DataSourcePasswordCipher("test-secret-key", true)); + BusinessDataSourceController controller = newController(service, true); MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); when(service.registerMysqlDataSource(request)).thenThrow(new IllegalArgumentException("bad datasource")); @@ -121,8 +150,7 @@ void shouldReturnFailureWhenRegisterFails() { @Test void shouldTestDataSourceThroughConsoleApi() { BusinessDataSourceService service = mock(BusinessDataSourceService.class); - BusinessDataSourceController controller = - new BusinessDataSourceController(service, new DataSourcePasswordCipher("test-secret-key", true)); + BusinessDataSourceController controller = newController(service, true); MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); MysqlDataSourceTestResult testResult = new MysqlDataSourceTestResult(); testResult.setSuccess(true); @@ -137,8 +165,7 @@ void shouldTestDataSourceThroughConsoleApi() { @Test void shouldUnregisterDataSourceThroughConsoleApi() { BusinessDataSourceService service = mock(BusinessDataSourceService.class); - BusinessDataSourceController controller = - new BusinessDataSourceController(service, new DataSourcePasswordCipher("test-secret-key", true)); + BusinessDataSourceController controller = newController(service, true); when(service.unregisterMysqlDataSource("shopDemo")).thenReturn("business-ds://shopDemo"); SingleResult result = controller.unregisterDataSource("shopDemo"); @@ -147,4 +174,12 @@ void shouldUnregisterDataSourceThroughConsoleApi() { assertEquals("business-ds://shopDemo", result.getData()); verify(service).unregisterMysqlDataSource("shopDemo"); } + + private BusinessDataSourceController newController( + BusinessDataSourceService service, boolean passwordCipherEnabled) { + return new BusinessDataSourceController( + service, + new DataSourcePasswordCipher("test-secret-key", passwordCipherEnabled), + new DataSourcePasswordTransportCipher()); + } } diff --git a/namingserver/src/main/resources/application.yml b/namingserver/src/main/resources/application.yml index 5030bf46ec0..285e3552099 100644 --- a/namingserver/src/main/resources/application.yml +++ b/namingserver/src/main/resources/application.yml @@ -57,7 +57,7 @@ seata: security: secretKey: SeataSecretKey0c382ef121d778043159209298fd40bf3850a017 tokenValidityInMilliseconds: 14400000 - csrf-ignore-urls: /naming/v1/**,/api/v1/naming/** + csrf-ignore-urls: /naming/v1/**,/api/v1/naming/**,/api/v1/businessDataSources/** ignore: urls: /,/**/*.css,/**/*.js,/**/*.html,/**/*.map,/**/*.svg,/**/*.png,/**/*.jpeg,/**/*.ico,/**/*.woff,/**/*.woff2,/**/*.ttf,/api/v1/auth/login,/version.json,/naming/v1/health,/error,/saga-statemachine-designer/** @@ -70,6 +70,10 @@ seata: # Business data source configuration businessDataSources: + encryption: + enabled: true + dynamic-registration: + enabled: true dataSource1: # Whether this data source is enabled enabled: true From a637eb3ddbcc56b1a2f62fccde570e2fa657b792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Thu, 4 Jun 2026 23:55:01 +0800 Subject: [PATCH 37/43] fix: validate dynamic datasource hosts --- .../props/BusinessDataSourcesProperties.java | 87 +++++++++++++++---- .../BusinessDataSourcesPropertiesTest.java | 18 +++- 2 files changed, 88 insertions(+), 17 deletions(-) diff --git a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java index 7c3514eefe8..ac5a5d92465 100644 --- a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java +++ b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java @@ -292,31 +292,36 @@ private void validateDynamicMysqlProperties(DataSourceProperties props) { if (!props.getUrl().toLowerCase(Locale.ROOT).startsWith("jdbc:mysql://")) { throw new IllegalArgumentException("Only jdbc:mysql:// URL is supported"); } - String host = parseMysqlHost(props.getUrl()); - if (!allowedHosts.isEmpty() && !allowedHosts.contains(host.toLowerCase(Locale.ROOT))) { - throw new IllegalArgumentException("MySQL host is not allowed: " + host); + if (allowedHosts.isEmpty()) { + throw new IllegalArgumentException("MySQL host allowlist cannot be empty for dynamic registration"); } - props.setDatabaseName(parseMysqlDatabaseName(props.getUrl())); - } - - private String parseMysqlHost(String url) { - try { - URI uri = URI.create(url.substring("jdbc:".length())); - if (!StringUtils.hasText(uri.getHost())) { - throw new IllegalArgumentException("MySQL host cannot be empty"); - } - return uri.getHost(); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("Invalid MySQL JDBC URL"); + MysqlJdbcUrl mysqlJdbcUrl = parseMysqlJdbcUrl(props.getUrl()); + if (!allowedHosts.contains(mysqlJdbcUrl.getNormalizedHost())) { + throw new IllegalArgumentException("MySQL host is not allowed: " + mysqlJdbcUrl.getHost()); } + props.setDatabaseName(mysqlJdbcUrl.getDatabaseName()); + props.setUrl(mysqlJdbcUrl.toJdbcUrl()); } private String parseMysqlDatabaseName(String url) { + return parseMysqlJdbcUrl(url).getDatabaseName(); + } + + private MysqlJdbcUrl parseMysqlJdbcUrl(String url) { if (!StringUtils.hasText(url) || !url.toLowerCase(Locale.ROOT).startsWith("jdbc:mysql://")) { throw new IllegalArgumentException("Only jdbc:mysql:// URL is supported"); } try { URI uri = URI.create(url.substring("jdbc:".length())); + if (StringUtils.hasText(uri.getUserInfo())) { + throw new IllegalArgumentException("MySQL JDBC URL cannot include user info"); + } + if (StringUtils.hasText(uri.getFragment())) { + throw new IllegalArgumentException("MySQL JDBC URL cannot include fragment"); + } + if (!StringUtils.hasText(uri.getHost())) { + throw new IllegalArgumentException("MySQL host cannot be empty"); + } String path = uri.getPath(); if (!StringUtils.hasText(path) || "/".equals(path)) { throw new IllegalArgumentException("MySQL JDBC URL must include a database name"); @@ -333,7 +338,11 @@ private String parseMysqlDatabaseName(String url) { if (SYSTEM_DATABASES.contains(normalized)) { throw new IllegalArgumentException("MySQL JDBC URL database is not allowed: " + databaseName); } - return databaseName; + int port = uri.getPort() < 0 ? 3306 : uri.getPort(); + if (port <= 0 || port > 65535) { + throw new IllegalArgumentException("MySQL port is invalid"); + } + return new MysqlJdbcUrl(uri.getHost(), port, databaseName, uri.getRawQuery()); } catch (IllegalArgumentException e) { throw e; } @@ -406,6 +415,52 @@ static void clear() { dynamicResourceIds.clear(); } + private static class MysqlJdbcUrl { + private final String host; + private final int port; + private final String databaseName; + private final String query; + + MysqlJdbcUrl(String host, int port, String databaseName, String query) { + this.host = host; + this.port = port; + this.databaseName = databaseName; + this.query = query; + } + + String getHost() { + return host; + } + + String getNormalizedHost() { + return host.toLowerCase(Locale.ROOT); + } + + String getDatabaseName() { + return databaseName; + } + + String toJdbcUrl() { + StringBuilder builder = new StringBuilder("jdbc:mysql://") + .append(formatHost(host)) + .append(":") + .append(port) + .append("/") + .append(databaseName); + if (StringUtils.hasText(query)) { + builder.append("?").append(query); + } + return builder.toString(); + } + + private String formatHost(String host) { + if (host.indexOf(':') >= 0 && !host.startsWith("[") && !host.endsWith("]")) { + return "[" + host + "]"; + } + return host; + } + } + public static class DataSourceProperties { private boolean enabled = true; private boolean dynamic; diff --git a/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java b/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java index 9095e8ce2ea..dab4690d7ce 100644 --- a/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java +++ b/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java @@ -65,6 +65,7 @@ void shouldResolvePlainPasswordAndRegisterMysqlResourceId() { BusinessDataSourcesProperties.getDatasources().get(resourceId); assertEquals("pwd", props.getPassword()); assertEquals("app", props.getDatabaseName()); + assertEquals("jdbc:mysql://localhost:3306/app", props.getUrl()); assertEquals( "business-ds://biz", BusinessDataSourcesProperties.getDataSourcesNamesAndResourceIds() @@ -91,6 +92,19 @@ void shouldResolvePasswordSecretRefAndRegisterMysqlResourceId() { assertEquals(1, properties.getMysqlDataSourceInfos().size()); } + @Test + void shouldRequireHostAllowlistForDynamicRegistration() { + MockEnvironment env = new MockEnvironment() + .withProperty("seata.businessDataSources.dynamic-registration.enabled", "true") + .withProperty("MYSQL_PASS", "pwd"); + BusinessDataSourcesProperties properties = newProperties(env); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> properties.registerMysqlDataSource(request("biz", "localhost"))); + + assertEquals("MySQL host allowlist cannot be empty for dynamic registration", exception.getMessage()); + } + @Test void shouldRequirePasswordSecretRefForDynamicRegistration() { BusinessDataSourcesProperties properties = newProperties(enabledEnv()); @@ -184,7 +198,9 @@ private BusinessDataSourcesProperties newProperties(MockEnvironment env) { } private MockEnvironment enabledEnv() { - return new MockEnvironment().withProperty("seata.businessDataSources.dynamic-registration.enabled", "true"); + return new MockEnvironment() + .withProperty("seata.businessDataSources.dynamic-registration.enabled", "true") + .withProperty("seata.businessDataSources.dynamic-registration.allowed-hosts", "localhost"); } private MysqlDataSourceRegisterRequest request(String name, String host) { From c280a474b9f9a6183813b728c043c6ee34aba41f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Thu, 4 Jun 2026 23:58:41 +0800 Subject: [PATCH 38/43] style: spotless check --- .../console-fe/src/service/businessDataSource.ts | 12 ++++-------- namingserver/src/main/resources/application.yml | 1 + 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/console/src/main/resources/static/console-fe/src/service/businessDataSource.ts b/console/src/main/resources/static/console-fe/src/service/businessDataSource.ts index b1d7f8d2b22..d72f670dacd 100644 --- a/console/src/main/resources/static/console-fe/src/service/businessDataSource.ts +++ b/console/src/main/resources/static/console-fe/src/service/businessDataSource.ts @@ -44,8 +44,6 @@ export type BusinessDataSourceTestResult = { elapsedMs: number; }; -let passwordPublicKeyPromise: Promise | null = null; - function base64ToArrayBuffer(base64: string): ArrayBuffer { const binary = window.atob(base64); const bytes = new Uint8Array(binary.length); @@ -65,12 +63,10 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string { } async function fetchPasswordPublicKey(): Promise { - if (!passwordPublicKeyPromise) { - passwordPublicKeyPromise = request('/businessDataSources/password/publicKey', { - method: 'get', - }).then(result => result.data); - } - return passwordPublicKeyPromise; + const result = await request('/businessDataSources/password/publicKey', { + method: 'get', + }); + return result.data; } async function encryptPassword(password: string): Promise { diff --git a/namingserver/src/main/resources/application.yml b/namingserver/src/main/resources/application.yml index 285e3552099..5844c7430c6 100644 --- a/namingserver/src/main/resources/application.yml +++ b/namingserver/src/main/resources/application.yml @@ -74,6 +74,7 @@ seata: enabled: true dynamic-registration: enabled: true + allowed-hosts: localhost dataSource1: # Whether this data source is enabled enabled: true From c440c8cbde63d52d37b0f7b38b57b5453d50bcf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Fri, 5 Jun 2026 00:20:10 +0800 Subject: [PATCH 39/43] fix: avoid tainted datasource jdbc urls --- .../props/BusinessDataSourcesProperties.java | 91 +++++++++++++------ .../impl/BusinessDataSourceServiceImpl.java | 1 + .../mcp/store/DbcpDataSourceProvider.java | 1 + .../mcp/store/DruidDataSourceProvider.java | 1 + .../mcp/store/HikariDataSourceProvider.java | 1 + .../db/AbstractMCPDataSourceProvider.java | 8 ++ .../BusinessDataSourcesPropertiesTest.java | 2 +- 7 files changed, 78 insertions(+), 27 deletions(-) diff --git a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java index ac5a5d92465..f86bd2ac680 100644 --- a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java +++ b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java @@ -59,7 +59,7 @@ public class BusinessDataSourcesProperties implements InitializingBean { private final boolean dynamicRegistrationEnabled; - private final Set allowedHosts; + private final Map allowedHosts; private static final Map datasources = new ConcurrentHashMap<>(); @@ -160,7 +160,6 @@ public synchronized DataSourceProperties buildDynamicMysqlProperties(MysqlDataSo props.setDynamic(true); props.setDbType(MYSQL_DB_TYPE); props.setDriverClassName(MYSQL_DRIVER_CLASS_NAME); - props.setUrl(request.getUrl()); props.setUsername(request.getUsername()); props.setPasswordSecretRef(request.getPasswordSecretRef()); props.setPassword(resolvePassword(request)); @@ -168,7 +167,7 @@ public synchronized DataSourceProperties buildDynamicMysqlProperties(MysqlDataSo props.setMinConn(request.getMinConn() <= 0 ? DEFAULT_DB_MIN_CONN : request.getMinConn()); props.setMaxConn(request.getMaxConn() <= 0 ? DEFAULT_DB_MAX_CONN : request.getMaxConn()); props.setMaxWait(request.getMaxWait() == null ? 5000L : request.getMaxWait()); - validateDynamicMysqlProperties(props); + validateDynamicMysqlProperties(props, request.getUrl()); if (!validateDataSourceProperties(props, props.getName())) { throw new IllegalArgumentException("Business DataSource Properties has failure"); } @@ -262,7 +261,9 @@ private boolean validateDataSourceProperties(DataSourceProperties props, String return false; } try { - props.setDatabaseName(parseMysqlDatabaseName(props.getUrl())); + if (!StringUtils.hasText(props.getDatabaseName())) { + props.setDatabaseName(parseMysqlDatabaseName(props.getUrl())); + } } catch (IllegalArgumentException e) { LOGGER.error("Invalid MySQL JDBC URL for datasource: {}", dataSourceName); return false; @@ -288,19 +289,20 @@ private boolean validateDataSourceProperties(DataSourceProperties props, String return true; } - private void validateDynamicMysqlProperties(DataSourceProperties props) { - if (!props.getUrl().toLowerCase(Locale.ROOT).startsWith("jdbc:mysql://")) { + private void validateDynamicMysqlProperties(DataSourceProperties props, String url) { + if (StringUtils.isBlank(url) || !url.toLowerCase(Locale.ROOT).startsWith("jdbc:mysql://")) { throw new IllegalArgumentException("Only jdbc:mysql:// URL is supported"); } if (allowedHosts.isEmpty()) { throw new IllegalArgumentException("MySQL host allowlist cannot be empty for dynamic registration"); } - MysqlJdbcUrl mysqlJdbcUrl = parseMysqlJdbcUrl(props.getUrl()); - if (!allowedHosts.contains(mysqlJdbcUrl.getNormalizedHost())) { + MysqlJdbcUrl mysqlJdbcUrl = parseMysqlJdbcUrl(url); + MysqlAllowedHost allowedHost = allowedHosts.get(mysqlJdbcUrl.getNormalizedAddress()); + if (allowedHost == null) { throw new IllegalArgumentException("MySQL host is not allowed: " + mysqlJdbcUrl.getHost()); } props.setDatabaseName(mysqlJdbcUrl.getDatabaseName()); - props.setUrl(mysqlJdbcUrl.toJdbcUrl()); + props.setUrl(allowedHost.toJdbcBaseUrl()); } private String parseMysqlDatabaseName(String url) { @@ -342,21 +344,43 @@ private MysqlJdbcUrl parseMysqlJdbcUrl(String url) { if (port <= 0 || port > 65535) { throw new IllegalArgumentException("MySQL port is invalid"); } - return new MysqlJdbcUrl(uri.getHost(), port, databaseName, uri.getRawQuery()); + return new MysqlJdbcUrl(uri.getHost(), port, databaseName); } catch (IllegalArgumentException e) { throw e; } } - private Set parseAllowedHosts(String hosts) { + private Map parseAllowedHosts(String hosts) { if (!StringUtils.hasText(hosts)) { - return Collections.emptySet(); + return Collections.emptyMap(); } return Arrays.stream(hosts.split(",")) .map(String::trim) .filter(StringUtils::isNotBlank) - .map(host -> host.toLowerCase(Locale.ROOT)) - .collect(Collectors.toSet()); + .map(this::parseAllowedHost) + .collect(Collectors.toMap(MysqlAllowedHost::getNormalizedAddress, host -> host, (left, right) -> left)); + } + + private MysqlAllowedHost parseAllowedHost(String hostConfig) { + String config = hostConfig; + if (config.toLowerCase(Locale.ROOT).startsWith("jdbc:mysql://")) { + MysqlJdbcUrl jdbcUrl = parseMysqlJdbcUrl(config); + return new MysqlAllowedHost(jdbcUrl.getHost(), jdbcUrl.getPort()); + } + String host = config; + int port = 3306; + int portSeparator = config.lastIndexOf(':'); + if (portSeparator > 0 && portSeparator < config.length() - 1 && config.indexOf(']') < portSeparator) { + host = config.substring(0, portSeparator); + port = Integer.parseInt(config.substring(portSeparator + 1)); + } + if (host.startsWith("[") && host.endsWith("]")) { + host = host.substring(1, host.length() - 1); + } + if (!StringUtils.hasText(host) || port <= 0 || port > 65535) { + throw new IllegalArgumentException("Invalid MySQL host allowlist entry"); + } + return new MysqlAllowedHost(host, port); } private String buildResourceId(String name) { @@ -419,37 +443,48 @@ private static class MysqlJdbcUrl { private final String host; private final int port; private final String databaseName; - private final String query; - MysqlJdbcUrl(String host, int port, String databaseName, String query) { + MysqlJdbcUrl(String host, int port, String databaseName) { this.host = host; this.port = port; this.databaseName = databaseName; - this.query = query; } String getHost() { return host; } - String getNormalizedHost() { - return host.toLowerCase(Locale.ROOT); + int getPort() { + return port; + } + + String getNormalizedAddress() { + return normalizeAddress(host, port); } String getDatabaseName() { return databaseName; } + } + + private static class MysqlAllowedHost { + private final String host; + private final int port; + + MysqlAllowedHost(String host, int port) { + this.host = host; + this.port = port; + } - String toJdbcUrl() { + String getNormalizedAddress() { + return normalizeAddress(host, port); + } + + String toJdbcBaseUrl() { StringBuilder builder = new StringBuilder("jdbc:mysql://") .append(formatHost(host)) .append(":") - .append(port) - .append("/") - .append(databaseName); - if (StringUtils.hasText(query)) { - builder.append("?").append(query); - } + .append(port); return builder.toString(); } @@ -461,6 +496,10 @@ private String formatHost(String host) { } } + private static String normalizeAddress(String host, int port) { + return host.toLowerCase(Locale.ROOT) + ":" + port; + } + public static class DataSourceProperties { private boolean enabled = true; private boolean dynamic; diff --git a/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java b/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java index 8f2859e1cb0..f1b08f7d8c3 100644 --- a/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java +++ b/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java @@ -94,6 +94,7 @@ public MysqlDataSourceTestResult testMysqlDataSource(MysqlDataSourceRegisterRequ try (Connection connection = DriverManager.getConnection(props.getUrl(), props.getUsername(), props.getPassword()); PreparedStatement statement = connection.prepareStatement(SqlConstant.MYSQL_VALIDATION_SQL)) { + connection.setCatalog(props.getDatabaseName()); statement.setQueryTimeout(5); statement.executeQuery(); } diff --git a/console/src/main/java/org/apache/seata/mcp/store/DbcpDataSourceProvider.java b/console/src/main/java/org/apache/seata/mcp/store/DbcpDataSourceProvider.java index ade7c11356b..a7a21779b3d 100644 --- a/console/src/main/java/org/apache/seata/mcp/store/DbcpDataSourceProvider.java +++ b/console/src/main/java/org/apache/seata/mcp/store/DbcpDataSourceProvider.java @@ -35,6 +35,7 @@ public DataSource doGenerate() { ds.setUrl(getUrl()); ds.setUsername(getUser()); ds.setPassword(getPassword()); + ds.setDefaultCatalog(getDatabaseName()); ds.setInitialSize(getMinConn()); ds.setMaxTotal(getMaxConn()); ds.setMinIdle(getMinConn()); diff --git a/console/src/main/java/org/apache/seata/mcp/store/DruidDataSourceProvider.java b/console/src/main/java/org/apache/seata/mcp/store/DruidDataSourceProvider.java index 1acdba0d905..f3dc0cd7248 100644 --- a/console/src/main/java/org/apache/seata/mcp/store/DruidDataSourceProvider.java +++ b/console/src/main/java/org/apache/seata/mcp/store/DruidDataSourceProvider.java @@ -35,6 +35,7 @@ public DataSource doGenerate() { ds.setUrl(getUrl()); ds.setUsername(getUser()); ds.setPassword(getPassword()); + ds.setDefaultCatalog(getDatabaseName()); ds.setInitialSize(getMinConn()); // Initial connection pool size ds.setMaxActive(getMaxConn()); // Maximum active connections diff --git a/console/src/main/java/org/apache/seata/mcp/store/HikariDataSourceProvider.java b/console/src/main/java/org/apache/seata/mcp/store/HikariDataSourceProvider.java index 8bc1902b563..9555c09fbd6 100644 --- a/console/src/main/java/org/apache/seata/mcp/store/HikariDataSourceProvider.java +++ b/console/src/main/java/org/apache/seata/mcp/store/HikariDataSourceProvider.java @@ -50,6 +50,7 @@ public DataSource doGenerate() { config.setJdbcUrl(getUrl()); config.setUsername(getUser()); config.setPassword(getPassword()); + config.setCatalog(getDatabaseName()); config.setMaximumPoolSize(getMaxConn()); // Maximum size of connection pool config.setMinimumIdle(getMinConn()); // Minimum number of idle connections diff --git a/console/src/main/java/org/apache/seata/mcp/store/db/AbstractMCPDataSourceProvider.java b/console/src/main/java/org/apache/seata/mcp/store/db/AbstractMCPDataSourceProvider.java index 99da503c55d..198d47d2eaa 100644 --- a/console/src/main/java/org/apache/seata/mcp/store/db/AbstractMCPDataSourceProvider.java +++ b/console/src/main/java/org/apache/seata/mcp/store/db/AbstractMCPDataSourceProvider.java @@ -204,6 +204,14 @@ protected String getUrl() { return url; } + protected String getDatabaseName() { + BusinessDataSourcesProperties.DataSourceProperties properties = getDataSourceProperties(); + if (properties == null || StringUtils.isBlank(properties.getDatabaseName())) { + throw new StoreException("the business datasource database name can't be empty"); + } + return properties.getDatabaseName(); + } + protected String getUser() { BusinessDataSourcesProperties.DataSourceProperties properties = getDataSourceProperties(); String username = ""; diff --git a/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java b/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java index dab4690d7ce..d2bac5babba 100644 --- a/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java +++ b/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java @@ -65,7 +65,7 @@ void shouldResolvePlainPasswordAndRegisterMysqlResourceId() { BusinessDataSourcesProperties.getDatasources().get(resourceId); assertEquals("pwd", props.getPassword()); assertEquals("app", props.getDatabaseName()); - assertEquals("jdbc:mysql://localhost:3306/app", props.getUrl()); + assertEquals("jdbc:mysql://localhost:3306", props.getUrl()); assertEquals( "business-ds://biz", BusinessDataSourcesProperties.getDataSourcesNamesAndResourceIds() From f3e60c87a91a13430f897436761860203d2721ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Fri, 5 Jun 2026 00:31:34 +0800 Subject: [PATCH 40/43] style: fix mcp checkstyle violations --- .../props/BusinessDataSourcesProperties.java | 50 +++++++++---------- .../seata/mcp/core/utils/DateUtils.java | 4 +- .../seata/mcp/store/DataSourceFactory.java | 16 +++--- 3 files changed, 36 insertions(+), 34 deletions(-) diff --git a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java index f86bd2ac680..b5a1ddaf535 100644 --- a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java +++ b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java @@ -61,11 +61,11 @@ public class BusinessDataSourcesProperties implements InitializingBean { private final Map allowedHosts; - private static final Map datasources = new ConcurrentHashMap<>(); + private static final Map DATASOURCES = new ConcurrentHashMap<>(); - private static final Map dataSourcesNamesAndResourceIds = new ConcurrentHashMap<>(); + private static final Map DATA_SOURCES_NAMES_AND_RESOURCE_IDS = new ConcurrentHashMap<>(); - private static final Set dynamicResourceIds = ConcurrentHashMap.newKeySet(); + private static final Set DYNAMIC_RESOURCE_IDS = ConcurrentHashMap.newKeySet(); private static final String BASE_PREFIX = "seata.businessDataSources."; @@ -123,8 +123,8 @@ public void afterPropertiesSet() { continue; } if (props.enabled) { - datasources.put(props.getResourceId(), props); - dataSourcesNamesAndResourceIds.put(name, props.getResourceId()); + DATASOURCES.put(props.getResourceId(), props); + DATA_SOURCES_NAMES_AND_RESOURCE_IDS.put(name, props.getResourceId()); } } } @@ -136,16 +136,16 @@ public synchronized String registerMysqlDataSource(MysqlDataSourceRegisterReques DataSourceProperties props = buildDynamicMysqlProperties(request); String name = props.getName(); String resourceId = props.getResourceId(); - if (dataSourcesNamesAndResourceIds.containsKey(name) || datasources.containsKey(resourceId)) { + if (DATA_SOURCES_NAMES_AND_RESOURCE_IDS.containsKey(name) || DATASOURCES.containsKey(resourceId)) { throw new IllegalArgumentException("The data source name has already been registered: " + name); } - if (dynamicResourceIds.size() >= maxDynamicDataSources) { + if (DYNAMIC_RESOURCE_IDS.size() >= maxDynamicDataSources) { throw new IllegalArgumentException( "The number of dynamic business data sources exceeds the limit: " + maxDynamicDataSources); } - datasources.put(resourceId, props); - dataSourcesNamesAndResourceIds.put(name, resourceId); - dynamicResourceIds.add(resourceId); + DATASOURCES.put(resourceId, props); + DATA_SOURCES_NAMES_AND_RESOURCE_IDS.put(name, resourceId); + DYNAMIC_RESOURCE_IDS.add(resourceId); return resourceId; } @@ -181,25 +181,25 @@ public synchronized String unregisterMysqlDataSource(String name) { if (!StringUtils.hasText(name)) { throw new IllegalArgumentException("The data source name cannot be empty"); } - String resourceId = dataSourcesNamesAndResourceIds.get(name); - if (!StringUtils.hasText(resourceId) || !dynamicResourceIds.contains(resourceId)) { + String resourceId = DATA_SOURCES_NAMES_AND_RESOURCE_IDS.get(name); + if (!StringUtils.hasText(resourceId) || !DYNAMIC_RESOURCE_IDS.contains(resourceId)) { throw new IllegalArgumentException("Dynamic data source is not registered: " + name); } - datasources.remove(resourceId); - dataSourcesNamesAndResourceIds.remove(name); - dynamicResourceIds.remove(resourceId); + DATASOURCES.remove(resourceId); + DATA_SOURCES_NAMES_AND_RESOURCE_IDS.remove(name); + DYNAMIC_RESOURCE_IDS.remove(resourceId); return resourceId; } public List getMysqlDataSourceInfos() { - return dataSourcesNamesAndResourceIds.entrySet().stream() - .map(entry -> toInfo(entry.getKey(), datasources.get(entry.getValue()))) + return DATA_SOURCES_NAMES_AND_RESOURCE_IDS.entrySet().stream() + .map(entry -> toInfo(entry.getKey(), DATASOURCES.get(entry.getValue()))) .filter(info -> info != null) .collect(Collectors.toList()); } public String getDatabaseName(String resourceId) { - DataSourceProperties props = datasources.get(resourceId); + DataSourceProperties props = DATASOURCES.get(resourceId); if (props == null) { throw new IllegalArgumentException("Cannot find datasource properties: " + resourceId); } @@ -418,25 +418,25 @@ private Set getDataSourceNames() { } public static Map getDatasources() { - return datasources; + return DATASOURCES; } public static Map getDataSourcesNamesAndResourceIds() { - return dataSourcesNamesAndResourceIds; + return DATA_SOURCES_NAMES_AND_RESOURCE_IDS; } public static Set getResourceIds() { - return datasources.keySet(); + return DATASOURCES.keySet(); } public static Set getDynamicResourceIds() { - return dynamicResourceIds; + return DYNAMIC_RESOURCE_IDS; } static void clear() { - datasources.clear(); - dataSourcesNamesAndResourceIds.clear(); - dynamicResourceIds.clear(); + DATASOURCES.clear(); + DATA_SOURCES_NAMES_AND_RESOURCE_IDS.clear(); + DYNAMIC_RESOURCE_IDS.clear(); } private static class MysqlJdbcUrl { diff --git a/console/src/main/java/org/apache/seata/mcp/core/utils/DateUtils.java b/console/src/main/java/org/apache/seata/mcp/core/utils/DateUtils.java index 237ec494e8f..f19e5471f53 100644 --- a/console/src/main/java/org/apache/seata/mcp/core/utils/DateUtils.java +++ b/console/src/main/java/org/apache/seata/mcp/core/utils/DateUtils.java @@ -70,7 +70,9 @@ public static String convertToDateTimeFromTimestamp(Long timestamp) { } public static boolean judgeExceedTimeDuration(Long startTime, Long endTime, Long maxDuration) { - if (endTime < startTime) throw new IllegalArgumentException("endTime must not be earlier than startTime"); + if (endTime < startTime) { + throw new IllegalArgumentException("endTime must not be earlier than startTime"); + } return endTime - startTime > maxDuration; } diff --git a/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java b/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java index 99158d5d85a..848812ef7b6 100644 --- a/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java +++ b/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java @@ -31,7 +31,7 @@ @Component public class DataSourceFactory { - private static final Map dataSourceMap = new ConcurrentHashMap<>(); + private static final Map DATA_SOURCE_MAP = new ConcurrentHashMap<>(); private static final Logger LOGGER = LoggerFactory.getLogger(DataSourceFactory.class); @@ -42,19 +42,19 @@ public void init() { @PreDestroy public void destroy() { - dataSourceMap.forEach(DataSourceFactory::closeDataSource); - dataSourceMap.clear(); + DATA_SOURCE_MAP.forEach(DataSourceFactory::closeDataSource); + DATA_SOURCE_MAP.clear(); } public static void initAllDataSources() { Map datasources = BusinessDataSourcesProperties.getDatasources(); - datasources.forEach( - (resourceId, props) -> dataSourceMap.computeIfAbsent(resourceId, key -> createDataSource(props, key))); + datasources.forEach((resourceId, props) -> + DATA_SOURCE_MAP.computeIfAbsent(resourceId, key -> createDataSource(props, key))); } public static DataSource getDataSource(String resourceId) { - return dataSourceMap.computeIfAbsent(resourceId, key -> { + return DATA_SOURCE_MAP.computeIfAbsent(resourceId, key -> { BusinessDataSourcesProperties.DataSourceProperties props = BusinessDataSourcesProperties.getDatasources().get(key); if (props == null) { @@ -65,13 +65,13 @@ public static DataSource getDataSource(String resourceId) { } public static void removeErrorDataSource(String resourceId, Exception e) { - closeDataSource(resourceId, dataSourceMap.remove(resourceId)); + closeDataSource(resourceId, DATA_SOURCE_MAP.remove(resourceId)); LOGGER.info("Delete Business DataSource, resourceId: {}", resourceId); throw new StoreException("The Business DataSource: " + resourceId + " can't be connected"); } public static void removeDataSource(String resourceId) { - closeDataSource(resourceId, dataSourceMap.remove(resourceId)); + closeDataSource(resourceId, DATA_SOURCE_MAP.remove(resourceId)); LOGGER.info("Delete Business DataSource, resourceId: {}", resourceId); } From 55201d77e914b646333974af0726572a7dcb5a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Fri, 5 Jun 2026 12:09:34 +0800 Subject: [PATCH 41/43] fix: report datasource test validation errors --- .../impl/BusinessDataSourceServiceImpl.java | 3 +++ .../BusinessDataSourceServiceImplTest.java | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java b/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java index f1b08f7d8c3..1afc8cd216f 100644 --- a/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java +++ b/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java @@ -100,6 +100,9 @@ public MysqlDataSourceTestResult testMysqlDataSource(MysqlDataSourceRegisterRequ } result.setSuccess(true); result.setMessage("OK"); + } catch (IllegalArgumentException e) { + result.setSuccess(false); + result.setMessage(e.getMessage()); } catch (Exception e) { result.setSuccess(false); result.setMessage("Connection test failed"); diff --git a/console/src/test/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImplTest.java b/console/src/test/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImplTest.java index 5fdbacfa627..fe744313e80 100644 --- a/console/src/test/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImplTest.java +++ b/console/src/test/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImplTest.java @@ -17,7 +17,9 @@ package org.apache.seata.mcp.service.impl; import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; +import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; import org.apache.seata.mcp.entity.vo.BusinessQueryResult; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceTestResult; import org.apache.seata.mcp.service.MysqlMetadataService; import org.apache.seata.mcp.store.DataSourceFactory; import org.apache.seata.mcp.store.SqlExecutionTemplate; @@ -73,4 +75,20 @@ void shouldQueryTableWithinUrlDatabase() { verify(sqlExecutionTemplate) .queryWithMaxRows("business-ds://biz", "SELECT `id`, `name` FROM `app`.`users`", 10); } + + @Test + void shouldExposeValidationMessageWhenTestingDataSource() { + SqlExecutionTemplate sqlExecutionTemplate = mock(SqlExecutionTemplate.class); + BusinessDataSourcesProperties properties = mock(BusinessDataSourcesProperties.class); + MysqlMetadataService metadataService = mock(MysqlMetadataService.class); + BusinessDataSourceServiceImpl service = new BusinessDataSourceServiceImpl( + sqlExecutionTemplate, properties, metadataService, new SqlSafetyValidator()); + MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); + when(properties.buildDynamicMysqlProperties(request)) + .thenThrow(new IllegalArgumentException("MySQL host is not allowed: 127.0.0.1")); + + MysqlDataSourceTestResult result = service.testMysqlDataSource(request); + + assertEquals("MySQL host is not allowed: 127.0.0.1", result.getMessage()); + } } From 25a8982366db50fa3c7235b1cfbf92ae86fcb110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Fri, 5 Jun 2026 12:58:41 +0800 Subject: [PATCH 42/43] fix: return sanitized datasource test errors --- .../impl/BusinessDataSourceServiceImpl.java | 59 ++++++++++++++++++- .../BusinessDataSourceServiceImplTest.java | 54 +++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java b/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java index 1afc8cd216f..0dff1d35936 100644 --- a/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java +++ b/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java @@ -31,11 +31,14 @@ import org.apache.seata.mcp.store.DataSourceFactory; import org.apache.seata.mcp.store.SqlExecutionTemplate; import org.apache.seata.mcp.store.SqlSafetyValidator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; +import java.sql.SQLException; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -46,6 +49,8 @@ public class BusinessDataSourceServiceImpl implements BusinessDataSourceService private static final Pattern IDENTIFIER_PATTERN = Pattern.compile("[A-Za-z0-9_$]+"); + private static final Logger LOGGER = LoggerFactory.getLogger(BusinessDataSourceServiceImpl.class); + private final SqlExecutionTemplate sqlExecutionTemplate; private final BusinessDataSourcesProperties businessDataSourcesProperties; @@ -101,11 +106,17 @@ public MysqlDataSourceTestResult testMysqlDataSource(MysqlDataSourceRegisterRequ result.setSuccess(true); result.setMessage("OK"); } catch (IllegalArgumentException e) { + LOGGER.warn("Business datasource connection test validation failed: {}", e.getMessage()); result.setSuccess(false); result.setMessage(e.getMessage()); + } catch (SQLException e) { + LOGGER.warn("Business datasource connection test failed: {}", sanitizeConnectionTestError(e, request)); + result.setSuccess(false); + result.setMessage(sanitizeConnectionTestError(e, request)); } catch (Exception e) { + LOGGER.warn("Business datasource connection test failed: {}", sanitizeConnectionTestError(e, request)); result.setSuccess(false); - result.setMessage("Connection test failed"); + result.setMessage(sanitizeConnectionTestError(e, request)); } result.setElapsedMs(System.currentTimeMillis() - start); return result; @@ -187,4 +198,50 @@ private void validateIdentifier(String field, String value) { private String quote(String identifier) { return "`" + identifier + "`"; } + + private String sanitizeConnectionTestError(Exception exception, MysqlDataSourceRegisterRequest request) { + StringBuilder message = new StringBuilder("Connection test failed"); + if (exception instanceof SQLException) { + SQLException sqlException = (SQLException) exception; + if (StringUtils.isNotBlank(sqlException.getSQLState())) { + message.append(" [SQLState: ") + .append(sqlException.getSQLState()) + .append("]"); + } + if (sqlException.getErrorCode() != 0) { + message.append(" [ErrorCode: ") + .append(sqlException.getErrorCode()) + .append("]"); + } + } else { + message.append(" [").append(exception.getClass().getSimpleName()).append("]"); + } + String detail = sanitizeSensitiveText(exception.getMessage(), request); + if (StringUtils.isNotBlank(detail)) { + message.append(": ").append(detail); + } + return message.toString(); + } + + private String sanitizeSensitiveText(String text, MysqlDataSourceRegisterRequest request) { + if (StringUtils.isBlank(text)) { + return ""; + } + String sanitized = text.replaceAll("jdbc:mysql://[^\\s,;]+", "jdbc:mysql://***"); + sanitized = sanitized.replaceAll("(?i)(password\\s*[=:]\\s*)[^\\s,;]+", "$1***"); + sanitized = sanitized.replaceAll("(?i)(user(name)?\\s*[=:]\\s*)[^\\s,;]+", "$1***"); + if (request != null) { + sanitized = replaceSensitiveValue(sanitized, request.getUrl()); + sanitized = replaceSensitiveValue(sanitized, request.getUsername()); + sanitized = replaceSensitiveValue(sanitized, request.getPassword()); + } + return sanitized; + } + + private String replaceSensitiveValue(String text, String sensitiveValue) { + if (StringUtils.isBlank(text) || StringUtils.isBlank(sensitiveValue)) { + return text; + } + return text.replace(sensitiveValue, "***"); + } } diff --git a/console/src/test/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImplTest.java b/console/src/test/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImplTest.java index fe744313e80..cfa7f8574d3 100644 --- a/console/src/test/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImplTest.java +++ b/console/src/test/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImplTest.java @@ -27,10 +27,14 @@ import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; +import java.lang.reflect.Method; +import java.sql.SQLException; import java.util.Arrays; import java.util.Collections; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verify; @@ -91,4 +95,54 @@ void shouldExposeValidationMessageWhenTestingDataSource() { assertEquals("MySQL host is not allowed: 127.0.0.1", result.getMessage()); } + + @Test + void shouldReturnSanitizedExceptionWhenTestingDataSource() { + SqlExecutionTemplate sqlExecutionTemplate = mock(SqlExecutionTemplate.class); + BusinessDataSourcesProperties properties = mock(BusinessDataSourcesProperties.class); + MysqlMetadataService metadataService = mock(MysqlMetadataService.class); + BusinessDataSourceServiceImpl service = new BusinessDataSourceServiceImpl( + sqlExecutionTemplate, properties, metadataService, new SqlSafetyValidator()); + MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); + request.setUrl("jdbc:mysql://localhost:3306/app"); + request.setUsername("root"); + request.setPassword("secret"); + RuntimeException exception = new RuntimeException( + "Access denied for user root using password=secret at jdbc:mysql://localhost:3306/app"); + when(properties.buildDynamicMysqlProperties(request)).thenThrow(exception); + + MysqlDataSourceTestResult result = service.testMysqlDataSource(request); + + assertFalse(result.isSuccess()); + assertTrue(result.getMessage().contains("RuntimeException")); + assertFalse(result.getMessage().contains("jdbc:mysql://localhost:3306/app")); + assertFalse(result.getMessage().contains("root")); + assertFalse(result.getMessage().contains("secret")); + } + + @Test + void shouldIncludeSqlStateAndErrorCodeInSanitizedConnectionMessage() throws Exception { + SqlExecutionTemplate sqlExecutionTemplate = mock(SqlExecutionTemplate.class); + BusinessDataSourcesProperties properties = mock(BusinessDataSourcesProperties.class); + MysqlMetadataService metadataService = mock(MysqlMetadataService.class); + BusinessDataSourceServiceImpl service = new BusinessDataSourceServiceImpl( + sqlExecutionTemplate, properties, metadataService, new SqlSafetyValidator()); + MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); + request.setUrl("jdbc:mysql://localhost:3306/app"); + request.setUsername("root"); + request.setPassword("secret"); + SQLException exception = new SQLException( + "Access denied for user root using password=secret at jdbc:mysql://localhost:3306/app", "28000", 1045); + Method method = BusinessDataSourceServiceImpl.class.getDeclaredMethod( + "sanitizeConnectionTestError", Exception.class, MysqlDataSourceRegisterRequest.class); + method.setAccessible(true); + + String message = (String) method.invoke(service, exception, request); + + assertTrue(message.contains("SQLState: 28000")); + assertTrue(message.contains("ErrorCode: 1045")); + assertFalse(message.contains("jdbc:mysql://localhost:3306/app")); + assertFalse(message.contains("root")); + assertFalse(message.contains("secret")); + } } From 69a9aa45417fc3dbe225104c7612b46b9a2262de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CXb2555=E2=80=9D?= <“2012753288@qq.com”> Date: Fri, 5 Jun 2026 13:02:06 +0800 Subject: [PATCH 43/43] fix: select database before datasource test query --- .../mcp/service/impl/BusinessDataSourceServiceImpl.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java b/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java index 0dff1d35936..15531c54e4b 100644 --- a/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java +++ b/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java @@ -97,11 +97,12 @@ public MysqlDataSourceTestResult testMysqlDataSource(MysqlDataSourceRegisterRequ businessDataSourcesProperties.buildDynamicMysqlProperties(request); Class.forName(props.getDriverClassName()); try (Connection connection = - DriverManager.getConnection(props.getUrl(), props.getUsername(), props.getPassword()); - PreparedStatement statement = connection.prepareStatement(SqlConstant.MYSQL_VALIDATION_SQL)) { + DriverManager.getConnection(props.getUrl(), props.getUsername(), props.getPassword())) { connection.setCatalog(props.getDatabaseName()); - statement.setQueryTimeout(5); - statement.executeQuery(); + try (PreparedStatement statement = connection.prepareStatement(SqlConstant.MYSQL_VALIDATION_SQL)) { + statement.setQueryTimeout(5); + statement.executeQuery(); + } } result.setSuccess(true); result.setMessage("OK");