Skip to content
Open
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
252b00e
optimize: move DBTYPE from core to common
Xb2555 Mar 8, 2026
261303c
feat: initialize business data source configuration instance
Xb2555 Mar 8, 2026
6116630
feat: Implement multiple types of connection pool instances
Xb2555 Mar 8, 2026
6de412d
feat: Implement the functions of initializing the business data sourc…
Xb2555 Mar 8, 2026
fd5a402
feat: Implement the underlying SQL statement execution function
Xb2555 Mar 8, 2026
29d6d7f
feat: Implement the business data source query function
Xb2555 Mar 8, 2026
db15f47
optimize: spotless check
Xb2555 Mar 8, 2026
a940624
optimize: Add data source configuration parameters
Xb2555 Mar 8, 2026
41f4659
Merge branch 'apache:2.x' into feat-dataSource
Xb2555 Mar 13, 2026
d54b641
Merge branch 'apache:2.x' into feat-dataSource
Xb2555 Mar 30, 2026
0691fbb
feature: introduce MySQL dependencies in NamingServer
Xb2555 Mar 30, 2026
aec66ae
optimize: spotless check
Xb2555 Mar 30, 2026
8a6f384
Merge branch '2.x' into feat-dataSource
Xb2555 Apr 10, 2026
bfe9fa6
Merge branch '2.x' into feat-dataSource
Xb2555 Jun 3, 2026
356a4b0
fix: secure MCP data source filter
Jun 3, 2026
fefaaba
fix: stop MCP filter after invalid config
Jun 3, 2026
aed31d8
fix: bound dynamic MCP data sources
Jun 3, 2026
1547c2b
fix: validate business data source pool sizes
Jun 3, 2026
9f34fd4
fix: allow SELECT queries with where clauses
Jun 3, 2026
777a9c9
fix: remove sample business datasource passwords
Jun 3, 2026
0b4d32f
fix: validate MCP datasource driver before generation
Jun 3, 2026
6d63cda
fix: include resource id in datasource error
Jun 3, 2026
2857e73
fix: close MCP data sources on shutdown
Jun 3, 2026
7e53522
fix: lookup MCP datasource properties dynamically
Jun 3, 2026
7d11e87
fix: clarify business datasource tool descriptions
Jun 3, 2026
8b8f356
fix: use managed MySQL connector dependency
Jun 3, 2026
8e6e173
style: format MCP datasource changes
Jun 3, 2026
b154354
test: add MCP datasource core behavior tests
Jun 3, 2026
c82eafc
feat: secure mysql mcp datasource tools
Jun 3, 2026
86b837e
refactor: simplify datasource mcp tool names
Jun 3, 2026
dbb8a16
build: compile console with java 25
Jun 3, 2026
9adfdd8
refactor: scope datasource tools to url database
Jun 3, 2026
b14eab4
refactor: remove plaintext password from datasource registration
Jun 3, 2026
2e5cb05
docs: describe datasource registration mcp params
Jun 3, 2026
3b7dd43
style: spotless check
Jun 3, 2026
5034459
refactor: move datasource registration to console api
Jun 4, 2026
7a0a8a3
feat: encrypt console datasource password input
Jun 4, 2026
ddb9799
refactor: use console secret key for datasource password cipher
Jun 4, 2026
b82db7d
feat: simplify datasource password handling
Jun 4, 2026
c2bd887
feat: add business datasource console page
Jun 4, 2026
a637eb3
fix: validate dynamic datasource hosts
Jun 4, 2026
c280a47
style: spotless check
Jun 4, 2026
c440c8c
fix: avoid tainted datasource jdbc urls
Jun 4, 2026
f3e60c8
style: fix mcp checkstyle violations
Jun 4, 2026
55201d7
fix: report datasource test validation errors
Jun 5, 2026
25a8982
fix: return sanitized datasource test errors
Jun 5, 2026
69a9aa4
fix: select database before datasource test query
Jun 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions console/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<description>console for Seata built with Maven</description>

<properties>
<java.version>25</java.version>
<spring-boot-for-server.version>3.5.2</spring-boot-for-server.version>
<spring-framework-for-server.version>6.2.8</spring-framework-for-server.version>
<snakeyaml-for-server.version>2.0</snakeyaml-for-server.version>
Expand Down Expand Up @@ -168,6 +169,22 @@
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-dbcp2</artifactId>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
@@ -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.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.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;
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;

private final DataSourcePasswordCipher passwordCipher;

private final DataSourcePasswordTransportCipher passwordTransportCipher;

public BusinessDataSourceController(
BusinessDataSourceService dataSourceService,
DataSourcePasswordCipher passwordCipher,
DataSourcePasswordTransportCipher passwordTransportCipher) {
this.dataSourceService = dataSourceService;
this.passwordCipher = passwordCipher;
this.passwordTransportCipher = passwordTransportCipher;
}

@GetMapping
public SingleResult<List<MysqlDataSourceInfo>> listDataSources() {
return SingleResult.success(dataSourceService.getMysqlDataSources());
}

@GetMapping("/password/publicKey")
public SingleResult<String> getPasswordPublicKey() {
return SingleResult.success(passwordTransportCipher.getPublicKey());
}

@PostMapping
public SingleResult<String> registerDataSource(@RequestBody MysqlDataSourceRegisterRequest request) {
try {
preparePassword(request);
return SingleResult.success(dataSourceService.registerMysqlDataSource(request));
} catch (Exception e) {
return SingleResult.failure(e.getMessage());
}
}

@PostMapping("/test")
public SingleResult<MysqlDataSourceTestResult> testDataSource(@RequestBody MysqlDataSourceRegisterRequest request) {
try {
preparePassword(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}")
public SingleResult<String> unregisterDataSource(@PathVariable String name) {
try {
return SingleResult.success(dataSourceService.unregisterMysqlDataSource(name));
} catch (Exception e) {
return SingleResult.failure(e.getMessage());
}
}

private void preparePassword(MysqlDataSourceRegisterRequest request) {
if (request == null) {
return;
}
if (StringUtils.isBlank(request.getPassword())) {
return;
}
if (passwordTransportCipher.isEncrypted(request.getPassword())) {
request.setPassword(passwordTransportCipher.decrypt(request.getPassword()));
return;
}
if (!passwordCipher.isEnabled()) {
return;
}
request.setPassword(passwordCipher.decrypt(request.getPassword()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* 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.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

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 static final String AES_ALGORITHM = "AES";

private static final String AES_GCM_TRANSFORMATION = "AES/GCM/NoPadding";

private static final int GCM_IV_LENGTH = 12;

private static final int GCM_TAG_LENGTH_BITS = 128;

private final SecretKeySpec secretKeySpec;

private final boolean enabled;

private final SecureRandom secureRandom = new SecureRandom();

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) {
if (StringUtils.isBlank(encryptedPassword)) {
return "";
}
try {
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");
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* 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 = ? "
+ "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 = ? ORDER BY ORDINAL_POSITION";

public static final String MYSQL_VALIDATION_SQL = "SELECT 1";

public static final String MYSQL_EXPLAIN_PREFIX = "EXPLAIN ";
}
Loading
Loading